Files
docutranslate/docutranslate/static/index.html
T
2026-04-15 13:57:05 +08:00

2011 lines
112 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>
/* 复用原版 CSS */
body {
background-color: var(--bs-body-bg);
}
button:focus {
outline: none !important;
box-shadow: none !important;
}
.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;
}
.task-area {
height: calc(100vh - 2rem);
overflow-y: auto;
}
.task-card {
transition: all 0.3s ease-in-out;
}
.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;
}
.log-area-wrapper {
position: relative;
}
.copy-log-btn {
position: absolute;
bottom: 5px;
right: 5px;
z-index: 10;
opacity: 0.5;
transition: all 0.2s ease-in-out;
padding: 0.1rem 0.4rem;
line-height: 1;
}
.copy-log-btn:hover {
opacity: 1;
}
.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);
}
#previewOffcanvas {
--bs-offcanvas-width: 95vw;
max-width: 1600px;
}
.preview-split-container {
display: flex;
flex-direction: row;
height: 90%;
}
.preview-pane-wrapper {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
}
.preview-pane-wrapper .preview-pane {
flex-grow: 1;
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
overflow: auto;
position: relative;
}
.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 {
width: 100%;
height: 100%;
border: none;
}
.preview-pane pre {
margin: 0;
padding: 1rem;
background-color: var(--bs-body-bg);
white-space: pre;
}
.bottom-left-controls {
position: fixed;
bottom: 1rem;
left: 1rem;
z-index: 1050;
display: flex;
gap: 0.5rem;
}
.step-number {
margin-right: 0.25rem;
}
#translatedPreviewContainer .preview-pane {
overflow: hidden;
}
#translatedPreviewContainer .preview-pane iframe {
display: block;
width: 100%;
height: 100%;
}
.lang-list-container {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--bs-border-color);
border-radius: 0.375rem;
padding: 0.5rem;
background-color: var(--bs-body-bg);
}
@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;
}
}
/* Vue Cloak to hide uncompiled templates */
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div id="app" v-cloak>
<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">
<div class="d-flex align-items-center">
<h4 class="mb-0 me-3 fw-bold" :title="t('pageTitle')">DocuTranslate</h4>
</div>
</div>
<form id="translateForm" @submit.prevent>
<div class="accordion" id="settingsAccordion">
<!-- 1. Workflow Selection -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseZero">
<strong><span class="step-number">1</span><i
class="bi bi-diagram-3 me-2"></i><span>{{ t('workflowTitle')
}}</span></strong>
</button>
</h2>
<div id="collapseZero" class="accordion-collapse collapse show">
<div class="accordion-body">
<select class="form-select" v-model="form.workflow_type"
@change="saveSetting('translator_last_workflow', form.workflow_type)">
<option value="markdown_based">{{ t('workflowOptionMarkdown') }}</option>
<option value="docx">{{ t('workflowOptionDocx') }}</option>
<option value="xlsx">{{ t('workflowOptionXlsx') }}</option>
<option value="epub">{{ t('workflowOptionEpub') }}</option>
<option value="txt">{{ t('workflowOptionTxt') }}</option>
<option value="pptx">{{ t('workflowOptionPptx') }}</option>
<option value="srt">{{ t('workflowOptionSrt') }}</option>
<option value="ass">{{ t('workflowOptionAss') }}</option>
<option value="json">{{ t('workflowOptionJson') }}</option>
<option value="html">{{ t('workflowOptionHtml') }}</option>
</select>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" role="switch"
id="autoWorkflowSwitch" v-model="form.auto_workflow_enabled"
@change="saveSetting('translator_auto_workflow_enabled', form.auto_workflow_enabled)">
<label class="form-check-label"
for="autoWorkflowSwitch">{{ t('autoWorkflowLabel') }}</label>
</div>
</div>
</div>
</div>
<!-- 2. Specific Workflow Settings (Conditional) -->
<div class="accordion-item" v-if="currentWorkflowConfig" v-show="true">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseWorkflow">
<strong><span class="step-number">{{ stepMap.specific }} </span><i
:class="currentWorkflowConfig.icon + ' me-2'"></i><span>{{ t(currentWorkflowConfig.titleKey)
}}</span></strong>
</button>
</h2>
<div id="collapseWorkflow" class="accordion-collapse collapse">
<div class="accordion-body">
<!-- Common Insert Mode -->
<div class="mb-3" v-if="currentWorkflowConfig.hasInsertMode">
<label class="form-label">{{ t('insertModeLabel') }}</label>
<select class="form-select"
v-model="workflowParams[form.workflow_type].insert_mode"
@change="saveWorkflowParam('insert_mode')">
<option value="replace">{{ t('insertModeReplace') }}</option>
<option value="append">{{ t('insertModeAppend') }}</option>
<option value="prepend">{{ t('insertModePrepend') }}</option>
</select>
<div class="form-text">
{{ t(currentWorkflowConfig.insertHelpKey || 'insertModeHelpTxt') }}
</div>
</div>
<!-- Common Separator -->
<div class="mb-3" v-if="currentWorkflowConfig.hasInsertMode"
v-show="['append', 'prepend'].includes(workflowParams[form.workflow_type].insert_mode)">
<label class="form-label">{{ t('separatorLabel') }}</label>
<input type="text" class="form-control"
v-model="workflowParams[form.workflow_type].separator"
@input="saveWorkflowParam('separator')"
:placeholder="t(currentWorkflowConfig.separatorPlaceholderKey || 'separatorPlaceholderSimple')">
<div class="form-text"
v-html="t(currentWorkflowConfig.separatorHelpKey || 'separatorHelp')"></div>
</div>
<!-- TXT Specific -->
<div class="mb-3" v-if="form.workflow_type === 'txt'">
<label class="form-label">{{ t('segmentModeLabel') }}</label>
<select class="form-select" v-model="workflowParams.txt.segment_mode"
@change="saveWorkflowParam('segment_mode')">
<option value="line">{{ t('segmentModeLine') }}</option>
<option value="paragraph">{{ t('segmentModeParagraph') }}</option>
<option value="none">{{ t('segmentModeNone') }}</option>
</select>
<div class="form-text">{{ t('segmentModeHelp') }}</div>
</div>
<!-- XLSX Specific -->
<div class="mb-3" v-if="form.workflow_type === 'xlsx'">
<label class="form-label">{{ t('xlsxTranslateRegionsLabel') }}</label>
<textarea class="form-control"
v-model="workflowParams.xlsx.translate_regions"
@input="saveWorkflowParam('translate_regions')" rows="3"
:placeholder="t('xlsxTranslateRegionsPlaceholder')"></textarea>
</div>
<!-- JSON Specific -->
<div class="mb-3" v-if="form.workflow_type === 'json'">
<label class="form-label">{{ t('jsonPathLabel') }}</label>
<textarea class="form-control" :class="{'is-invalid': errors.json_paths}"
v-model="workflowParams.json.json_paths"
@input="saveWorkflowParam('json_paths'); clearError('json_paths')"
rows="4" required
:placeholder="t('jsonPathPlaceholder')"></textarea>
<div class="form-text" v-html="t('jsonPathHelp')"></div>
</div>
</div>
</div>
</div>
<!-- 3. Parsing Settings (Markdown Based Only) -->
<div class="accordion-item" v-if="form.workflow_type === 'markdown_based'">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseOne">
<strong><span class="step-number">{{ stepMap.parsing }} </span><i
class="bi bi-file-earmark-binary me-2"></i><span>{{ t('parsingSettingsTitleText')
}}</span></strong>
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse">
<div class="accordion-body">
<div class="mb-3">
<label class="form-label">{{ t('parsingEngineLabel') }}</label>
<select class="form-select" v-model="form.convert_engine"
@change="saveSetting('translator_convert_engin', form.convert_engine)">
<option value="identity" v-if="showIdentityOption">
{{ t('engineOptionIdentity') || '已经是markdown' }}
</option>
<option v-for="eng in enginList" :key="eng" :value="eng">
{{ t('engineOption' + capitalize(eng)) || eng }}
</option>
</select>
<div class="form-text">{{ t('parsingEngineHelp') }}</div>
</div>
<!-- Mineru Cloud Config -->
<div v-if="form.convert_engine === 'mineru'">
<div class="mb-3">
<label class="form-label">Mineru Token <a
href="https://mineru.net/apiManage/token" target="_blank"
class="ms-1"><i
class="bi bi-box-arrow-up-right"></i></a></label>
<div class="input-group">
<input :type="showMineruToken ? 'text' : 'password'"
autocomplete="new-password" class="form-control"
:class="{'is-invalid': errors.mineru_token}"
v-model="form.mineru_token"
@input="saveSetting('translator_mineru_token', form.mineru_token); clearError('mineru_token')"
:placeholder="t('mineruTokenPlaceholder')">
<button class="btn btn-outline-secondary" type="button"
@click="showMineruToken = !showMineruToken"><i class="bi"
:class="showMineruToken ? 'bi-eye' : 'bi-eye-slash'"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('modelVersionLabel') }}</label>
<select class="form-select" v-model="form.model_version"
@change="saveSetting('translator_model_version', form.model_version)">
<option value="vlm">{{ t('modelVersionVlm') }}</option>
<option value="pipeline">{{ t('modelVersionPipline') }}</option>
</select>
<div class="form-text">{{ t('modelVersionHelp') }}</div>
</div>
</div>
<!-- Mineru Local Deploy Config -->
<div v-if="form.convert_engine === 'mineru_deploy'"
class="border p-3 rounded mb-3">
<div class="mb-3">
<label class="form-label">{{ t('mineruDeployBaseUrlLabel') }}</label>
<input type="url" class="form-control"
:class="{'is-invalid': errors.mineru_deploy_base_url}"
v-model="form.mineru_deploy_base_url"
@input="saveSetting('mineru_deploy_base_url', form.mineru_deploy_base_url); clearError('mineru_deploy_base_url')"
required :placeholder="t('mineruDeployBaseUrlPlaceholder')">
</div>
<div class="mb-3">
<label class="form-label">{{ t('mineruDeployBackendLabel') }}</label>
<select class="form-select" v-model="form.mineru_deploy_backend"
@change="saveSetting('mineru_deploy_backend', form.mineru_deploy_backend)">
<option value="pipeline">pipeline</option>
<option value="vlm-auto-engine">vlm-auto-engine</option>
<option value="vlm-http-client">vlm-http-client</option>
<option value="hybrid-auto-engine">hybrid-auto-engine</option>
<option value="hybrid-http-client">hybrid-http-client</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">{{ t('mineruDeployParseMethodLabel') }}</label>
<select class="form-select" v-model="form.mineru_deploy_parse_method"
@change="saveSetting('mineru_deploy_parse_method', form.mineru_deploy_parse_method)">
<option value="auto">auto</option>
<option value="txt">txt</option>
<option value="ocr">ocr</option>
</select>
</div>
<!-- Condition: If Backend is Pipeline or Hybrid, show Lang List -->
<div class="mb-3" v-if="['pipeline', 'hybrid-auto-engine', 'hybrid-http-client'].includes(form.mineru_deploy_backend)">
<label class="form-label">{{ t('mineruDeployLangListLabel') }}</label>
<div class="lang-list-container">
<div class="form-check" v-for="lang in mineruLangOptions"
:key="lang.val">
<input class="form-check-input" type="checkbox"
:value="lang.val"
:id="'langCheck-'+lang.val"
v-model="form.mineru_deploy_lang_list"
@change="saveSettingArray('mineru_deploy_lang_list', form.mineru_deploy_lang_list)">
<label class="form-check-label" :for="'langCheck-'+lang.val">
{{ lang.label }}
</label>
</div>
</div>
</div>
<!-- Condition: If Backend is vlm-http-client or hybrid-http-client, show Server URL -->
<div class="mb-3" v-if="['vlm-http-client', 'hybrid-http-client'].includes(form.mineru_deploy_backend)">
<label class="form-label">{{ t('mineruDeployServerUrlLabel') }}</label>
<input type="url" class="form-control"
v-model="form.mineru_deploy_server_url"
@input="saveSetting('mineru_deploy_server_url', form.mineru_deploy_server_url)"
:placeholder="t('mineruDeployServerUrlPlaceholder')">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">{{ t('mineruDeployStartPageLabel')
}}</label>
<input type="number" class="form-control"
v-model="form.mineru_deploy_start_page"
@input="saveSetting('mineru_deploy_start_page', form.mineru_deploy_start_page)"
min="0">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">{{ t('mineruDeployEndPageLabel')
}}</label>
<input type="number" class="form-control"
v-model="form.mineru_deploy_end_page"
@input="saveSetting('mineru_deploy_end_page', form.mineru_deploy_end_page)"
min="0">
</div>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch"
v-model="form.mineru_deploy_formula_enable"
@change="saveSetting('mineru_deploy_formula_enable', form.mineru_deploy_formula_enable)">
<label class="form-check-label">{{ t('mineruDeployFormulaEnableLabel')
}}</label>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch"
v-model="form.mineru_deploy_table_enable"
@change="saveSetting('mineru_deploy_table_enable', form.mineru_deploy_table_enable)">
<label class="form-check-label">{{ t('mineruDeployTableEnableLabel')
}}</label>
</div>
</div>
<div class="border-top mt-3 pt-3">
<div class="form-check form-switch mb-2" v-if="ocrOptions.showFormula">
<input class="form-check-input" type="checkbox" role="switch"
v-model="form.formula_ocr"
@change="saveSetting('translator_formula_ocr', form.formula_ocr)">
<label class="form-check-label">{{ t('formulaOcrLabel') }}</label>
</div>
<div class="form-check form-switch mb-2" v-if="ocrOptions.showCode">
<input class="form-check-input" type="checkbox" role="switch"
v-model="form.code_ocr"
@change="saveSetting('translator_code_ocr', form.code_ocr)">
<label class="form-check-label">{{ t('codeOcrLabel') }}</label>
</div>
</div>
</div>
</div>
</div>
<!-- 4. AI Settings -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseTwo">
<strong><span class="step-number">{{ stepMap.ai }} </span><i
class="bi bi-robot me-2"></i><span>{{ t('aiSettingsTitleText') }}</span></strong>
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse">
<div class="accordion-body">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch"
v-model="form.skip_translate"
@change="saveSetting('translator_skip_translate', form.skip_translate)">
<label class="form-check-label">{{ t('skipTranslationLabel') }}</label>
</div>
<div v-show="!form.skip_translate">
<model-preset-selector
v-model:model-preset="form.model_preset"
:presets="modelPresets"
:invalid-model-preset="errors.model_preset"
@clear-error="clearError"
:t="t"></model-preset-selector>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch"
v-model="form.system_proxy_enable"
@change="saveSetting('translator_system_proxy_enable', form.system_proxy_enable)">
<label class="form-check-label">{{ t('systemProxyLabel') }}</label>
</div>
<div class="d-flex align-items-center mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
v-model="form.force_json"
@change="saveSetting('translator_force_json', form.force_json)">
<label class="form-check-label">{{ t('forceJson') }}</label>
</div>
<i class="bi bi-question-circle ms-2" data-bs-toggle="tooltip"
:title="t('forceJsonTooltip')"></i>
</div>
</div>
</div>
</div>
</div>
<!-- 5. Translation Settings -->
<div class="accordion-item" v-show="!form.skip_translate">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseThree">
<strong><span class="step-number">{{ stepMap.trans }} </span><i
class="bi bi-translate me-2"></i><span>{{ t('translationSettingsTitleText')
}}</span></strong>
</button>
</h2>
<div id="collapseThree" class="accordion-collapse collapse">
<div class="accordion-body">
<div class="mb-3">
<label class="form-label">{{ t('targetLanguageLabel') }}</label>
<select class="form-select" v-model="form.to_lang"
@change="saveSetting('translator_to_lang', form.to_lang)">
<option value="Simplified Chinese">中文(简体中文)</option>
<option value="English">英文(English)</option>
<option value="Spanish">西班牙文(Español)</option>
<option value="French">法文(Français)</option>
<option value="German">德文(Deutsch)</option>
<option value="Japanese">日文(日本語)</option>
<option value="Korean">韩文(한국어)</option>
<option value="Russian">俄文(Русский)</option>
<option value="Portuguese">葡萄牙文(Português)</option>
<option value="Arabic">阿拉伯文(العَرَبِيَّة)</option>
<option value="Vietnamese">越南文(tiếng Việt)</option>
<option value="Indonesian">印尼文(Bahasa Indonesia)</option>
<option value="custom">{{ t('targetLanguageCustom') }}</option>
</select>
<div class="mt-2" v-if="form.to_lang === 'custom'">
<input type="text" class="form-control"
:class="{'is-invalid': errors.custom_to_lang}"
v-model="form.custom_to_lang"
@input="saveSetting('translator_custom_to_lang', form.custom_to_lang); clearError('custom_to_lang')"
:placeholder="t('customLangPlaceholder')">
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('thinkingModeLabel') }}</label>
<i class="bi bi-question-circle ms-2" data-bs-toggle="tooltip"
:title="t('thinkingModeTooltip')"></i>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" value="enable" id="thinkEn"
v-model="form.thinking"
@change="saveSetting('translator_thinking_mode', 'enable')">
<label class="btn btn-outline-primary"
for="thinkEn">{{ t('thinkingModeEnable') }}</label>
<input type="radio" class="btn-check" value="disable" id="thinkDis"
v-model="form.thinking"
@change="saveSetting('translator_thinking_mode', 'disable')">
<label class="btn btn-outline-primary"
for="thinkDis">{{ t('thinkingModeDisable') }}</label>
<input type="radio" class="btn-check" value="default" id="thinkDef"
v-model="form.thinking"
@change="saveSetting('translator_thinking_mode', 'default')">
<label class="btn btn-outline-primary"
for="thinkDef">{{ t('thinkingModeDefault') }}</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('customPromptLabel') }}</label>
<textarea class="form-control" v-model="form.custom_prompt"
@input="saveSetting('custom_prompt', form.custom_prompt)" rows="3"
:placeholder="t('customPromptPlaceholder')"></textarea>
</div>
<slider-control :label="t('chunkSizeLabel')" v-model="form.chunk_size"
save-key="chunk_size" :default-val="defaultParams.chunk_size"
:min="1000" :max="12000" :step="100" :t="t"></slider-control>
<slider-control :label="t('concurrentLabel')" v-model="form.concurrent"
save-key="concurrent" :default-val="defaultParams.concurrent"
:min="1" :max="120" :step="1" :t="t"></slider-control>
<slider-control label="Temperature" v-model="form.temperature"
save-key="temperature" :default-val="defaultParams.temperature"
:min="0" :max="2" :step="0.1" :t="t"></slider-control>
<slider-control :label="t('retryLabel')" v-model="form.retry" save-key="retry"
:default-val="defaultParams.retry" :min="1" :max="6" :step="1"
:t="t"></slider-control>
<!-- New RPM/TPM Settings [Vertical Layout] -->
<div class="mb-3">
<label class="form-label">RPM <small class="text-muted">({{ t('rpmLabel')
}})</small></label>
<input type="number" class="form-control" v-model="form.rpm"
@input="saveSetting('rpm', form.rpm)"
min="1" :placeholder="t('unlimitedPlaceholder')">
</div>
<div class="mb-3">
<label class="form-label">TPM <small class="text-muted">({{ t('tpmLabel')
}})</small></label>
<input type="number" class="form-control" v-model="form.tpm"
@input="saveSetting('tpm', form.tpm)"
min="1" :placeholder="t('unlimitedPlaceholder')">
</div>
</div>
</div>
</div>
<!-- 6. Glossary Settings -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseGlossary">
<strong><i
class="bi bi-journal-bookmark me-2"></i><span>{{ t('glossaryGenTitle')
}}</span></strong>
</button>
</h2>
<div id="collapseGlossary" class="accordion-collapse collapse">
<div class="accordion-body">
<div class="mb-3">
<label class="form-label">{{ t('glossaryLabel') }}</label>
<input class="form-control" type="file" @change="handleGlossaryFiles"
multiple accept=".csv" ref="glossaryInput">
<div class="form-text">{{ t('glossaryHelp') }}</div>
<div class="btn-group mt-2">
<button type="button" class="btn btn-sm btn-outline-info"
@click="openGlossaryModal">
<i class="bi bi-card-list me-1"></i><span>{{ t('viewGlossaryBtn') }} <span
v-if="glossaryCount">({{glossaryCount}})</span></span>
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
@click="clearGlossary">
<i class="bi bi-trash me-1"></i><span>{{ t('clearGlossaryBtn')
}}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- Import/Export -->
<div class="d-flex justify-content-center gap-2 mt-4">
<button type="button" class="btn btn-outline-primary" @click="configFile.click()"><i
class="bi bi-box-arrow-in-down me-1"></i><span>{{ t('importConfigBtn') }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" @click="exportConfig"><i
class="bi bi-box-arrow-up me-1"></i><span>{{ t('exportConfigBtn') }}</span></button>
</div>
<input type="file" ref="configFile" class="d-none" accept=".json" @change="importConfig">
<!-- Project Info -->
<div class="mt-4 text-center text-muted small project-info">
<p class="bi mb-0">version:<span>{{ version ? 'v' + version : '' }}</span></p>
</div>
</div>
</div>
<!-- Right: Task Area -->
<div class="col-lg-8">
<div class="task-area">
<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><span>{{ t('taskListTitle') }}</span></h4>
<button class="btn btn-primary" @click="createNewTask()"><i
class="bi bi-plus-circle-fill me-2"></i><span>{{ t('newTaskBtn') }}</span></button>
</div>
<div id="task-container">
<div v-if="tasks.length === 0" class="text-center text-muted mt-5">
<img src="/static/favicon.ico" alt="LOGO" style="width:10%;min-width: 55px; height: auto;">
<p class="mt-3">{{ t('noTaskPlaceholder') }}</p>
</div>
<div v-for="task in tasks" :key="task.uiId" class="card mb-3 task-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-bold"><span>{{ t('taskCardIdLabel')
}}</span>: <code>{{ task.backendId || t('taskCardIdPlaceholder') }}</code></span>
<button type="button" class="btn-close" @click="removeTask(task)"></button>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-5">
<input type="file" class="d-none" :id="'fileInput-' + task.uiId"
@change="handleTaskFileSelect($event, task)">
<div class="file-drop-area"
:class="{'drag-over': task.isDragOver, 'file-selected': !!task.file, 'input-error': task.validationError}"
@click="triggerFileInput(task.uiId)"
@dragenter.prevent="task.isDragOver = true"
@dragover.prevent="task.isDragOver = true"
@dragleave.prevent="task.isDragOver = false"
@drop.prevent="handleTaskFileDrop($event, task)">
<div v-if="!task.file" class="file-drop-default">
<i class="bi bi-cloud-arrow-up fs-1"></i>
<p class="mb-0">{{ t('taskCardFileDrop') }}</p>
</div>
<div v-else class="file-drop-selected">
<i class="bi bi-check-circle-fill fs-1 text-success"></i>
<p class="mb-0 mt-2 fw-bold text-success">{{ t('taskCardFileSelected')
}}</p>
</div>
</div>
<div class="mt-2" v-if="task.file || task.fileName">
<span class="fw-bold">{{ t('taskCardFilenameLabel') }} </span><span
class="text-success">{{ task.fileName || task.file.name }}</span>
</div>
</div>
<div class="col-md-7">
<h6><i class="bi bi-terminal me-2"></i><span>{{ t('taskCardLogLabel') }}</span>
</h6>
<div class="log-area-wrapper">
<div class="log-area" v-html="task.logs" :id="'log-' + task.uiId"></div>
<button type="button" class="btn btn-sm btn-outline-secondary copy-log-btn"
@click="copyLog($event, task.logs)" data-bs-toggle="tooltip"
:title="t('copyLogsTooltip')">
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="mt-2">
<span class="small"
:class="task.statusClass">{{ task.statusMessage || t('taskCardStatusWaiting')
}}</span>
<div v-if="task.isProcessing" class="spinner-border spinner-border-sm ms-2"
role="status"></div>
</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="download-buttons" v-if="task.downloads">
<button class="btn btn-sm btn-success me-1" v-if="task.downloads.html"
@click="openPreview(task)"><i
class="bi bi-eye-fill me-1"></i><span>{{ t('taskCardPreviewBtn') }}</span>
</button>
<div class="btn-group me-1">
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle"
data-bs-toggle="dropdown"><i
class="bi bi-download me-1"></i><span>{{ t('taskCardDownloadBtn')
}}</span></button>
<ul class="dropdown-menu">
<li v-for="(link, key) in task.downloads" :key="key">
<a class="dropdown-item" :href="link"><i class="bi me-2"
:class="getFileIcon(key)"></i>{{ key === 'markdown_zip' ? t('downloadMdZip') : (key === 'markdown' ? t('downloadMdEmbedded') : key.toUpperCase())
}}</a>
</li>
<li v-if="task.downloads.html">
<a class="dropdown-item" href="#"
@click.prevent="printPdf(task.downloads.html)"><i
class="bi bi-file-earmark-pdf me-2"></i>PDF</a>
</li>
</ul>
</div>
<div class="btn-group"
v-if="task.attachment && Object.keys(task.attachment).length">
<button type="button" class="btn btn-sm btn-info dropdown-toggle"
data-bs-toggle="dropdown"><i
class="bi bi-paperclip me-1"></i><span>{{ t('taskCardAttachmentBtn')
}}</span></button>
<ul class="dropdown-menu">
<li v-for="(link, name) in task.attachment" :key="name"><a
class="dropdown-item" :href="link"><i
class="bi bi-file-earmark-arrow-down me-2"></i>{{ name }}</a></li>
</ul>
</div>
</div>
<button class="btn ms-auto" :class="task.isTranslating ? 'btn-danger' : 'btn-primary'"
@click="toggleTaskState(task)" :disabled="task.initializing">
<span v-if="task.initializing"><span
class="spinner-border spinner-border-sm"></span> {{ t('btn_initializing') }}</span>
<span v-else-if="task.isTranslating"><i
class="bi bi-stop-circle-fill me-1"></i>{{ t('btn_cancelTranslation')
}}</span>
<span v-else-if="task.isFinished"><i
class="bi bi-arrow-clockwise me-1"></i>{{ t('btn_reTranslate') }}</span>
<span v-else><i class="bi bi-play-fill me-1"></i>{{ t('taskCardStartBtn') }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
<div class="modal fade" id="glossaryModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">{{ t('glossaryModalTitle') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>{{ t('glossaryTableSource') }}</th>
<th>{{ t('glossaryTableDestination') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(dst, src) in glossaryData" :key="src">
<td>{{ src }}</td>
<td>{{ dst }}</td>
</tr>
<tr v-if="Object.keys(glossaryData).length === 0">
<td colspan="2" class="text-center text-muted">{{ t('glossaryEmpty') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('closeBtn') }}</button>
</div>
</div>
</div>
</div>
<!-- Preview Offcanvas -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="previewOffcanvas" ref="previewOffcanvas">
<div class="offcanvas-header border-bottom">
<h5 class="offcanvas-title">
{{ previewMode === 'bilingual' ? t('preview_bilingual') : t('preview_translatedOnly') }}</h5>
<div class="btn-group me-auto ms-4">
<button class="btn btn-sm" :class="previewMode === 'bilingual' ? 'btn-primary' : 'btn-outline-primary'"
@click="setPreviewMode('bilingual')">{{ t('previewBilingualBtn') }}
</button>
<button class="btn btn-sm"
:class="previewMode === 'translatedOnly' ? 'btn-primary' : 'btn-outline-primary'"
@click="setPreviewMode('translatedOnly')">{{ t('previewTranslatedOnlyBtn') }}
</button>
</div>
<button class="btn btn-sm btn-outline-secondary ms-2"
:class="{active: syncScrollEnabled, 'btn-primary': syncScrollEnabled}" @click="toggleSyncScroll"
:title="t('syncScrollTooltip')"><i class="bi"
:class="syncScrollEnabled ? 'bi-link' : 'bi-link-45deg'"></i>
</button>
<!-- New Download Dropdown in Preview Header -->
<div class="btn-group ms-2" v-if="previewTask && previewTask.downloads">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-download me-1"></i><span>{{ t('taskCardDownloadBtn') }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li v-for="(link, key) in previewTask.downloads" :key="key">
<a class="dropdown-item" :href="link">
<i class="bi me-2" :class="getFileIcon(key)"></i>
{{ key === 'markdown_zip' ? t('downloadMdZip') : (key === 'markdown' ? t('downloadMdEmbedded') : key.toUpperCase())
}}
</a>
</li>
<li v-if="previewTask.downloads.html">
<a class="dropdown-item" href="#" @click.prevent="printPdf(previewTask.downloads.html)">
<i class="bi bi-file-earmark-pdf me-2"></i>PDF
</a>
</li>
</ul>
</div>
<button type="button" class="btn-close ms-2" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body d-flex flex-column p-2">
<div class="preview-split-container flex-grow-1" ref="splitContainer">
<div id="originalPreviewContainer" class="preview-pane-wrapper" v-show="previewMode === 'bilingual'">
<h6 class="text-center text-muted small">{{ t('previewOriginal') }}</h6>
<div class="preview-pane" ref="originalPane"></div>
</div>
<div id="translatedPreviewContainer" class="preview-pane-wrapper">
<h6 class="text-center text-muted small">{{ t('previewTranslated') }}</h6>
<div class="preview-pane">
<iframe ref="translatedFrame" src="about:blank"></iframe>
</div>
</div>
</div>
</div>
</div>
<iframe id="printFrame" ref="printFrame" style="display: none;"></iframe>
<!-- Controls -->
<div class="bottom-left-controls">
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"><i
class="bi bi-translate"></i></button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" :class="{active: currentLang==='zh'}" href="#"
@click.prevent="setLang('zh')">中文</a></li>
<li><a class="dropdown-item" :class="{active: currentLang==='en'}" href="#"
@click.prevent="setLang('en')">English</a></li>
<li><a class="dropdown-item" :class="{active: currentLang==='vi'}" href="#"
@click.prevent="setLang('vi')">Tiếng Việt</a></li>
</ul>
</div>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"><i
class="bi bi-circle-half"></i></button>
<ul class="dropdown-menu">
<li>
<button class="dropdown-item" @click="setTheme('light')"><i class="bi bi-sun-fill me-2"></i> Light
</button>
</li>
<li>
<button class="dropdown-item" @click="setTheme('dark')"><i class="bi bi-moon-stars-fill me-2"></i>
Dark
</button>
</li>
<li>
<button class="dropdown-item" @click="setTheme('auto')"><i class="bi bi-circle-half me-2"></i> Auto
</button>
</li>
</ul>
</div>
</div>
</div>
<script src="/static/bootstrap.bundle.min.js"></script>
<script src="/static/vue.global.prod.js"></script>
<script src="/static/split.min.js"></script>
<script src="/static/papaparse.min.js"></script>
<script>
const {createApp, ref, reactive, computed, watch, onMounted, nextTick} = Vue;
const SliderControl = {
props: ['label', 'modelValue', 'min', 'max', 'step', 'defaultVal', 't', 'saveKey'],
template: `
<div class="mb-3">
<label class="form-label d-flex justify-content-between">
<span>{{ label }}: <span>{{ modelValue }}</span></span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
:style="{visibility: modelValue != defaultVal ? 'visible':'hidden'}" @click="reset">
{{ t('resetBtn') }}
</button>
</label>
<input type="range" class="form-range" :min="min" :max="max" :step="step" :value="modelValue"
@input="update">
</div>`,
setup(props, {emit}) {
const update = (e) => {
const val = Number(e.target.value);
emit('update:modelValue', val);
if (props.saveKey) localStorage.setItem(props.saveKey, val);
};
const reset = () => {
emit('update:modelValue', props.defaultVal);
if (props.saveKey) localStorage.setItem(props.saveKey, props.defaultVal);
};
return {update, reset};
}
};
const ModelPresetSelector = {
props: ['modelPreset', 'presets', 't', 'invalidModelPreset'],
template: `
<div>
<div class="mb-3">
<label class="form-label">{{ t('modelPresetLabel') }}</label>
<select class="form-select" :class="{'is-invalid': invalidModelPreset}"
:value="modelPreset" :disabled="!presets.length"
@change="handlePresetChange($event.target.value)">
<option value="" disabled>{{ presets.length ? t('modelPresetPlaceholder') : t('modelPresetEmpty') }}</option>
<option v-for="preset in presets" :key="preset.id" :value="preset.id">{{ preset.label }}</option>
</select>
<div class="form-text mt-2" v-if="presets.length">{{ t('modelPresetRuntimeHint') }}</div>
<div class="form-text mt-2" v-else>{{ t('modelPresetEmpty') }}</div>
</div>
</div>`,
setup(props, {emit}) {
const handlePresetChange = (val) => {
emit('update:modelPreset', val);
emit('clearError', 'model_preset');
localStorage.setItem('translator_model_preset', val);
};
return {
handlePresetChange
};
}
};
const mineruLangOptions = [
{val: "ch", label: "ch"},
{val: "ch_server", label: "ch_server"},
{val: "ch_lite", label: "ch_lite"},
{val: "en", label: "en"},
{val: "korean", label: "korean"},
{val: "japan", label: "japan"},
{val: "chinese_cht", label: "chinese_cht"},
{val: "ta", label: "ta"},
{val: "te", label: "te"},
{val: "ka", label: "ka"},
{val: "th", label: "th"},
{val: "el", label: "el"},
{val: "latin", label: "latin"},
{val: "arabic", label: "arabic"},
{val: "east_slavic", label: "east_slavic"},
{val: "cyrillic", label: "cyrillic"},
{val: "devanagari", label: "devanagari"}
];
createApp({
components: {SliderControl, ModelPresetSelector},
setup() {
const version = ref("");
const currentLang = ref(localStorage.getItem('ui_language') || 'zh');
const i18nData = ref({});
const glossaryData = ref({});
const tasks = ref([]);
const enginList = ref([]);
const defaultParams = reactive({});
const modelPresets = ref([]);
const defaultModelPreset = ref('');
// Refs for DOM elements
const glossaryInput = ref(null);
const configFile = ref(null);
const previewOffcanvas = ref(null);
// UI State
const showMineruToken = ref(false);
const previewMode = ref('bilingual');
const syncScrollEnabled = ref(localStorage.getItem('ui_sync_scroll_enabled') === 'true');
const showIdentityOption = ref(true);
// Validation State
const errors = reactive({
model_preset: false,
mineru_token: false,
mineru_deploy_base_url: false,
custom_to_lang: false,
json_paths: false
});
const clearError = (field) => {
if (errors[field]) errors[field] = false;
};
// Form Data
const form = reactive({
workflow_type: 'markdown_based',
auto_workflow_enabled: true,
convert_engine: 'mineru',
mineru_token: '',
model_version: 'vlm',
mineru_deploy_base_url: 'http://127.0.0.1:8000',
mineru_deploy_backend: 'hybrid-auto-engine', // Updated default
mineru_deploy_parse_method: 'auto', // Added
mineru_deploy_start_page: 0,
mineru_deploy_end_page: 99999,
mineru_deploy_formula_enable: true,
mineru_deploy_table_enable: true, // Added
mineru_deploy_lang_list: [],
mineru_deploy_server_url: '',
formula_ocr: true,
code_ocr: true,
skip_translate: false,
model_preset: '',
system_proxy_enable: false,
force_json: false,
to_lang: 'Simplified Chinese',
custom_to_lang: '',
thinking: 'disable',
custom_prompt: '',
chunk_size: 1000,
concurrent: 5,
temperature: 0.1,
retry: 3,
rpm: null, // New RPM
tpm: null, // New TPM
});
// Nested Params for specific workflows
const workflowParams = reactive({
txt: {insert_mode: 'replace', separator: '\\n', segment_mode: 'line'},
xlsx: {insert_mode: 'replace', separator: '\\n', translate_regions: ''},
docx: {insert_mode: 'replace', separator: ''},
srt: {insert_mode: 'replace', separator: '\\n'},
epub: {insert_mode: 'replace', separator: ''},
html: {insert_mode: 'replace', separator: ''},
ass: {insert_mode: 'replace', separator: '\\N'},
json: {json_paths: ''},
pptx: {insert_mode: 'replace', separator: '\\n'}
});
// Load config from localStorage
const loadConfig = () => {
const get = (k, def) => localStorage.getItem(k) || def;
const getBool = (k, def) => {
const v = localStorage.getItem(k);
return v === null ? def : v === 'true';
};
const getNum = (k, def) => {
const v = localStorage.getItem(k);
return v ? Number(v) : def;
};
const getNumOrNull = (k) => {
const v = localStorage.getItem(k);
return (v === null || v === '' || v === 'null') ? null : Number(v);
};
const validPresetIds = modelPresets.value.map(p => p.id);
const fallbackPreset = validPresetIds.includes(defaultModelPreset.value)
? defaultModelPreset.value
: (validPresetIds[0] || '');
form.workflow_type = get('translator_last_workflow', 'docx');
form.auto_workflow_enabled = getBool('translator_auto_workflow_enabled', true);
form.convert_engine = get('translator_convert_engin', 'mineru');
form.mineru_token = get('translator_mineru_token', '');
form.model_version = get('translator_model_version', 'vlm');
form.mineru_deploy_base_url = get('mineru_deploy_base_url', 'http://127.0.0.1:8000');
form.mineru_deploy_backend = get('mineru_deploy_backend', 'hybrid-auto-engine');
form.mineru_deploy_parse_method = get('mineru_deploy_parse_method', 'auto');
form.mineru_deploy_start_page = getNum('mineru_deploy_start_page', 0);
form.mineru_deploy_end_page = getNum('mineru_deploy_end_page', 99999);
form.mineru_deploy_formula_enable = getBool('mineru_deploy_formula_enable', true);
form.mineru_deploy_table_enable = getBool('mineru_deploy_table_enable', true);
form.mineru_deploy_server_url = get('mineru_deploy_server_url', '');
const savedLangList = localStorage.getItem('mineru_deploy_lang_list');
form.mineru_deploy_lang_list = savedLangList ? JSON.parse(savedLangList) : [];
form.formula_ocr = getBool('translator_formula_ocr', true);
form.code_ocr = getBool('translator_code_ocr', true);
form.skip_translate = getBool('translator_skip_translate', false);
form.model_preset = get('translator_model_preset', fallbackPreset);
form.system_proxy_enable = getBool('translator_system_proxy_enable', false);
form.force_json = getBool('translator_force_json', false);
form.to_lang = get('translator_to_lang', 'Simplified Chinese');
form.custom_to_lang = get('translator_custom_to_lang', '');
form.thinking = get('translator_thinking_mode', 'default');
form.custom_prompt = get('custom_prompt', '');
form.chunk_size = getNum('chunk_size', 1000);
form.concurrent = getNum('concurrent', 5);
form.temperature = getNum('temperature', 0.1);
form.retry = getNum('retry', 3);
form.rpm = getNumOrNull('rpm'); // Load RPM
form.tpm = getNumOrNull('tpm'); // Load TPM
if (!validPresetIds.includes(form.model_preset)) {
form.model_preset = fallbackPreset;
}
// Restore workflow specific params
['txt', 'xlsx', 'docx', 'srt', 'epub', 'html', 'ass', 'pptx'].forEach(t => {
workflowParams[t].insert_mode = get(`translator_${t}_insert_mode`, 'replace');
if (workflowParams[t].separator !== undefined) workflowParams[t].separator = get(`translator_${t}_separator`, workflowParams[t].separator);
});
workflowParams.txt.segment_mode = get('translator_txt_segment_mode', 'line');
workflowParams.xlsx.translate_regions = get('translator_xlsx_translate_regions', '');
workflowParams.json.json_paths = get('translator_json_paths', '');
};
// --- 新增:专门用于将当前 form 数据全部写入 localStorage 的函数 ---
const saveAllSettings = () => {
const s = (k, v) => localStorage.setItem(k, v); // 简写保存函数
const f = form;
// 1. 基础配置映射 (手动映射那些名字不一样的)
s('translator_last_workflow', f.workflow_type);
s('translator_auto_workflow_enabled', f.auto_workflow_enabled);
s('translator_convert_engin', f.convert_engine);
s('translator_mineru_token', f.mineru_token);
s('translator_model_version', f.model_version);
// Mineru Deploy 相关
s('mineru_deploy_base_url', f.mineru_deploy_base_url);
s('mineru_deploy_backend', f.mineru_deploy_backend);
s('mineru_deploy_parse_method', f.mineru_deploy_parse_method);
s('mineru_deploy_start_page', f.mineru_deploy_start_page);
s('mineru_deploy_end_page', f.mineru_deploy_end_page);
s('mineru_deploy_formula_enable', f.mineru_deploy_formula_enable);
s('mineru_deploy_table_enable', f.mineru_deploy_table_enable);
s('mineru_deploy_server_url', f.mineru_deploy_server_url);
s('mineru_deploy_lang_list', JSON.stringify(f.mineru_deploy_lang_list));
// 杂项
s('translator_formula_ocr', f.formula_ocr);
s('translator_code_ocr', f.code_ocr);
s('translator_skip_translate', f.skip_translate);
s('translator_model_preset', f.model_preset);
s('translator_system_proxy_enable', f.system_proxy_enable);
s('translator_force_json', f.force_json);
s('translator_to_lang', f.to_lang);
s('translator_custom_to_lang', f.custom_to_lang);
s('translator_thinking_mode', f.thinking);
// 名字一样的直接存
s('custom_prompt', f.custom_prompt);
s('chunk_size', f.chunk_size);
s('concurrent', f.concurrent);
s('temperature', f.temperature);
s('retry', f.retry);
s('rpm', f.rpm || '');
s('tpm', f.tpm || '');
// 2. 自动循环保存所有具体工作流参数 (txt, docx, xlsx...)
for (const [wfType, params] of Object.entries(workflowParams)) {
for (const [key, val] of Object.entries(params)) {
s(`translator_${wfType}_${key}`, val);
}
}
};
const t = (k) => {
const dict = i18nData.value[currentLang.value] || i18nData.value['zh'] || {};
return dict[k] || k;
};
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
const saveSetting = (k, v) => localStorage.setItem(k, v);
const saveSettingArray = (k, v) => localStorage.setItem(k, JSON.stringify(v));
const syncModelPresetSelection = () => {
const validPresetIds = modelPresets.value.map(p => p.id);
const fallbackPreset = validPresetIds.includes(defaultModelPreset.value)
? defaultModelPreset.value
: (validPresetIds[0] || '');
if (!validPresetIds.includes(form.model_preset)) {
form.model_preset = fallbackPreset;
}
};
const saveWorkflowParam = (keySuffix) => {
const wf = form.workflow_type;
localStorage.setItem(`translator_${wf}_${keySuffix}`, workflowParams[wf][keySuffix]);
};
const currentWorkflowConfig = computed(() => {
const map = {
'txt': {
titleKey: 'txtSettingsTitleText',
icon: 'bi-filetype-txt',
hasInsertMode: true,
insertHelpKey: 'insertModeHelpTxt'
},
'docx': {
titleKey: 'docxSettingsTitleText',
icon: 'bi-file-earmark-word',
hasInsertMode: true,
insertHelpKey: 'insertModeHelpDocx'
},
'xlsx': {
titleKey: 'xlsxSettingsTitleText',
icon: 'bi-file-earmark-spreadsheet',
hasInsertMode: true,
insertHelpKey: 'insertModeHelpXlsx'
},
'srt': {
titleKey: 'srtSettingsTitleText',
icon: 'bi-file-text',
hasInsertMode: true,
insertHelpKey: 'insertModeHelpSrt'
},
'epub': {
titleKey: 'epubSettingsTitleText',
icon: 'bi-book',
hasInsertMode: true,
insertHelpKey: 'insertModeHelpEpub'
},
'html': {
titleKey: 'htmlSettingsTitleText',
icon: 'bi-filetype-html',
hasInsertMode: true,
insertHelpKey: 'insertModeHelpHtml'
},
'ass': {
titleKey: 'assSettingsTitleText',
icon: 'bi-file-easel',
hasInsertMode: true,
insertHelpKey: 'insertModeHelpAss',
separatorHelpKey: 'separatorHelpAss',
separatorPlaceholderKey: 'separatorPlaceholderAss'
},
'pptx': {
titleKey: 'pptxSettingsTitleText',
icon: 'bi-file-slides',
hasInsertMode: true,
insertHelpKey: 'insertModeHelpPptx'
},
'json': {titleKey: 'jsonSettingsTitleText', icon: 'bi-signpost-split', hasInsertMode: false},
};
return map[form.workflow_type];
});
// Dynamic Step Numbering
const stepMap = computed(() => {
let step = 2;
const map = {specific: 0, parsing: 0, ai: 0, trans: 0};
if (currentWorkflowConfig.value) map.specific = step++;
if (form.workflow_type === 'markdown_based') map.parsing = step++;
map.ai = step++;
if (!form.skip_translate) map.trans = step++;
return map;
});
const ocrOptions = computed(() => ({
showFormula: ['mineru', 'docling'].includes(form.convert_engine),
showCode: form.convert_engine === 'docling'
}));
const glossaryCount = computed(() => Object.keys(glossaryData.value).length);
const handleGlossaryFiles = (e) => {
const files = e.target.files;
if (!files.length) return;
Array.from(files).forEach(f => {
Papa.parse(f, {
header: true, skipEmptyLines: true,
complete: (res) => {
if (res.data) res.data.forEach(r => {
if (r.src && r.dst) glossaryData.value[r.src.trim()] = r.dst.trim();
});
}
});
});
};
const clearGlossary = () => {
glossaryData.value = {};
if (glossaryInput.value) glossaryInput.value.value = '';
};
const openGlossaryModal = () => new bootstrap.Modal(document.getElementById('glossaryModal')).show();
// Task Management
const createNewTask = (backendId = null) => {
const uiId = 'card_' + Math.random().toString(36).substring(2, 9);
const task = reactive({
uiId, backendId, file: null, fileName: '', logs: '', statusMessage: '',
statusClass: 'text-muted', isTranslating: false, isFinished: false, isProcessing: false,
validationError: false, downloads: null, attachment: null, initializing: false, isDragOver: false
});
tasks.value.unshift(task);
if (backendId) {
task.isTranslating = true;
pollStatus(task);
}
};
const removeTask = async (task) => {
if (task.backendId) try {
await fetch(`/service/release/${task.backendId}`, {method: 'POST'});
} catch (e) {
}
tasks.value = tasks.value.filter(t => t.uiId !== task.uiId);
saveActiveTasks();
};
const saveActiveTasks = () => {
if (window.location.pathname.includes('/admin')) return;
const ids = tasks.value.map(t => t.backendId).filter(id => id);
localStorage.setItem('active_task_ids', JSON.stringify(ids));
};
const handleTaskFileSelect = (e, task) => {
const f = e.target.files ? e.target.files[0] : e.dataTransfer.files[0];
if (f) {
task.file = f;
task.fileName = f.name; // Save filename explicitly
task.validationError = false;
task.isDragOver = false;
if (form.auto_workflow_enabled) {
const ext = f.name.split('.').pop().toLowerCase();
const map = {
txt: 'txt',
xlsx: 'xlsx',
csv: 'xlsx',
xls: 'xlsx',
docx: 'docx',
doc: 'docx',
json: 'json',
srt: 'srt',
epub: 'epub',
html: 'html',
htm: 'html',
ass: 'ass',
pptx: 'pptx',
ppt: 'pptx'
};
const newWorkflow = map[ext] || 'markdown_based';
form.workflow_type = newWorkflow;
saveSetting('translator_last_workflow', newWorkflow);
}
}
};
const handleTaskFileDrop = (e, task) => handleTaskFileSelect(e, task);
const triggerFileInput = (uiId) => {
const el = document.getElementById('fileInput-' + uiId);
if (el) el.click();
};
const buildPayload = () => {
// 辅助函数:将空字符串转换为 null,确保后端 exclude_none 生效
const emptyToNull = (val) => (!val && val !== 0 && val !== false) ? null : val;
// Clone basic form
const basePayload = {
skip_translate: form.skip_translate,
model_preset: emptyToNull(form.model_preset),
to_lang: form.to_lang === 'custom' ? form.custom_to_lang : form.to_lang,
thinking: form.thinking,
chunk_size: Number(form.chunk_size),
concurrent: Number(form.concurrent),
temperature: Number(form.temperature),
retry: Number(form.retry),
custom_prompt: emptyToNull(form.custom_prompt),
glossary_dict: Object.keys(glossaryData.value).length ? glossaryData.value : null,
system_proxy_enable: form.system_proxy_enable,
force_json: form.force_json,
workflow_type: form.workflow_type,
rpm: emptyToNull(form.rpm),
tpm: emptyToNull(form.tpm)
};
// Specific Workflow Params
if (form.workflow_type === 'markdown_based') {
basePayload.convert_engine = form.convert_engine;
if (form.convert_engine === 'mineru') {
basePayload.mineru_token = emptyToNull(form.mineru_token);
basePayload.model_version = form.model_version;
basePayload.formula_ocr = form.formula_ocr;
} else if (form.convert_engine === 'mineru_deploy') {
basePayload.mineru_deploy_base_url = emptyToNull(form.mineru_deploy_base_url);
basePayload.mineru_deploy_backend = form.mineru_deploy_backend;
basePayload.mineru_deploy_parse_method = form.mineru_deploy_parse_method; // Added
basePayload.mineru_deploy_formula_enable = form.mineru_deploy_formula_enable;
basePayload.mineru_deploy_table_enable = form.mineru_deploy_table_enable; // Added
basePayload.mineru_deploy_start_page_id = parseInt(form.mineru_deploy_start_page) || 0;
basePayload.mineru_deploy_end_page_id = parseInt(form.mineru_deploy_end_page) || 99999;
// Condition: Pipeline or Hybrid backends support lang_list
if (['pipeline', 'hybrid-auto-engine', 'hybrid-http-client'].includes(basePayload.mineru_deploy_backend)) {
basePayload.mineru_deploy_lang_list = form.mineru_deploy_lang_list.length > 0 ? form.mineru_deploy_lang_list : null;
}
// Condition: HTTP Client backends need server_url
if (['vlm-http-client', 'hybrid-http-client'].includes(basePayload.mineru_deploy_backend)) {
basePayload.mineru_deploy_server_url = emptyToNull(form.mineru_deploy_server_url);
}
} else if (form.convert_engine === 'docling') {
basePayload.code_ocr = form.code_ocr;
basePayload.formula_ocr = form.formula_ocr;
}
} else {
const params = {...workflowParams[form.workflow_type]};
if (params.separator) {
if (form.workflow_type === 'ass') params.separator = params.separator;
else params.separator = params.separator.replace(/\\n/g, '\n');
}
if (form.workflow_type === 'json') {
params.json_paths = params.json_paths.split('\n').map(p => p.trim()).filter(p => p);
} else if (form.workflow_type === 'xlsx') {
if (params.translate_regions && typeof params.translate_regions === 'string' && params.translate_regions.trim()) {
params.translate_regions = params.translate_regions.split('\n').map(p => p.trim()).filter(p => p);
if (params.translate_regions.length === 0) delete params.translate_regions;
} else {
delete params.translate_regions;
}
}
Object.assign(basePayload, params);
}
return basePayload;
};
const validateForm = () => {
let isValid = true;
Object.keys(errors).forEach(k => errors[k] = false);
if (!form.skip_translate) {
if (!form.model_preset) {
errors.model_preset = true;
isValid = false;
}
if (form.to_lang === 'custom' && !form.custom_to_lang) {
errors.custom_to_lang = true;
isValid = false;
}
}
if (form.workflow_type === 'markdown_based') {
if (form.convert_engine === 'mineru' && !form.mineru_token) {
errors.mineru_token = true;
isValid = false;
}
if (form.convert_engine === 'mineru_deploy' && !form.mineru_deploy_base_url) {
errors.mineru_deploy_base_url = true;
isValid = false;
}
} else if (form.workflow_type === 'json') {
if (!workflowParams.json.json_paths || !workflowParams.json.json_paths.trim()) {
errors.json_paths = true;
isValid = false;
}
}
if (!isValid) {
nextTick(() => {
const errorEl = document.querySelector('.is-invalid');
if (errorEl) {
errorEl.scrollIntoView({behavior: 'smooth', block: 'center'});
errorEl.focus();
}
});
}
return isValid;
};
const toggleTaskState = async (task) => {
if (task.isTranslating) {
task.statusMessage = t('status_cancelling');
if (task.backendId) await fetch(`/service/cancel/${task.backendId}`, {method: 'POST'});
} else if (task.isFinished) {
task.isFinished = false;
task.logs = '';
task.downloads = null;
toggleTaskState(task);
} else {
if (!task.file) {
task.validationError = true;
return;
}
if (!validateForm()) {
task.statusMessage = t('status_fillRequired') || "请检查左侧配置项 (Please check settings)";
task.statusClass = 'text-danger';
return;
}
task.initializing = true;
const reader = new FileReader();
reader.onload = async () => {
const base64 = reader.result.split(',')[1];
try {
const res = await fetch('/service/translate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
file_name: task.file.name,
file_content: base64,
payload: buildPayload()
})
});
const data = await res.json();
if (res.ok && data.task_started) {
task.backendId = data.task_id;
task.isTranslating = true;
saveActiveTasks();
pollStatus(task);
task.statusMessage = data.message;
} else {
throw new Error(data.message || data.detail || 'Error');
}
} catch (e) {
task.statusMessage = e.message;
task.statusClass = 'text-danger';
task.isTranslating = false;
} finally {
task.initializing = false;
}
};
reader.readAsDataURL(task.file);
}
};
const pollStatus = (task) => {
const interval = setInterval(async () => {
if (!task.isTranslating) {
clearInterval(interval);
return;
}
try {
// Fetch Logs
const logRes = await fetch(`/service/logs/${task.backendId}`);
const logData = await logRes.json();
if (logData.logs && logData.logs.length) {
task.logs += logData.logs.map(l => l.replace(/</g, "&lt;").replace(/>/g, "&gt;")).join('<br>') + '<br>';
nextTick(() => {
const logEl = document.getElementById('log-' + task.uiId);
if (logEl) logEl.scrollTop = logEl.scrollHeight;
});
}
// Fetch Status
const statRes = await fetch(`/service/status/${task.backendId}`);
// Handle 404 (Task not found / Expired)
if (!statRes.ok) {
if (statRes.status === 404) {
clearInterval(interval);
task.isTranslating = false;
task.isProcessing = false;
task.statusClass = 'text-danger';
task.statusMessage = '任务不存在或已过期 (Task not found)';
const savedIds = JSON.parse(localStorage.getItem('active_task_ids') || '[]');
const newIds = savedIds.filter(id => id !== task.backendId);
localStorage.setItem('active_task_ids', JSON.stringify(newIds));
}
return;
}
const statData = await statRes.json();
task.statusMessage = statData.status_message;
task.isProcessing = statData.is_processing;
// Recover filename if task was restored and file object is missing
if (statData.original_filename && !task.fileName) {
task.fileName = statData.original_filename;
}
if (!statData.is_processing) {
clearInterval(interval);
task.isTranslating = false;
task.isFinished = true;
if (statData.download_ready && !statData.error_flag) {
task.downloads = statData.downloads;
task.attachment = statData.attachment;
task.statusClass = 'text-success';
} else {
task.statusClass = 'text-danger';
task.statusMessage = statData.error_flag ? (statData.status_message || 'Failed') : statData.status_message;
}
}
} catch (e) {
}
}, 1500);
};
// Preview Logic
const splitInstance = ref(null);
const splitContainer = ref(null);
const originalPane = ref(null);
const translatedFrame = ref(null);
const previewTask = ref(null);
const initSplit = () => {
if (splitInstance.value) {
splitInstance.value.destroy();
splitInstance.value = null;
}
const isMobile = window.innerWidth < 992;
if (splitContainer.value) {
splitContainer.value.style.flexDirection = isMobile ? 'column' : 'row';
}
if (previewMode.value === 'bilingual') {
nextTick(() => {
const el1 = document.getElementById('originalPreviewContainer');
const el2 = document.getElementById('translatedPreviewContainer');
if (el1 && el2) {
splitInstance.value = Split(['#originalPreviewContainer', '#translatedPreviewContainer'], {
sizes: [50, 50], minSize: 150, gutterSize: 10,
direction: isMobile ? 'vertical' : 'horizontal',
cursor: isMobile ? 'row-resize' : 'col-resize'
});
}
});
}
setupSyncScroll();
};
const openPreview = (task) => {
previewTask.value = task;
const offcanvas = new bootstrap.Offcanvas(document.getElementById('previewOffcanvas'));
offcanvas.show();
// Load Original Content
originalPane.value.innerHTML = '';
if (task.file) {
const ext = task.file.name.split('.').pop().toLowerCase();
if (['txt', 'md', 'json', 'html', 'js', 'py', 'css', 'java', 'c', 'cpp'].includes(ext) || task.file.type.startsWith('text/')) {
task.file.text().then(txt => originalPane.value.innerHTML = `<pre>${txt}</pre>`);
} else if (['pdf'].includes(ext) || task.file.type === 'application/pdf') {
const iframe = document.createElement('iframe');
iframe.src = URL.createObjectURL(task.file);
originalPane.value.appendChild(iframe);
} else {
originalPane.value.innerHTML = `<p class="p-3 text-muted">${t('preview_cantPreviewType')} (${ext})</p>`;
}
} else {
originalPane.value.innerHTML = `<p class="p-3 text-muted">${t('preview_noOriginalCache')}</p>`;
}
// Load Translated Content
if (translatedFrame.value) translatedFrame.value.src = 'about:blank';
fetch(task.downloads.html).then(r => r.text()).then(h => {
if (translatedFrame.value) translatedFrame.value.srcdoc = h;
});
// Re-init Split.js and Sync Scroll listeners
setTimeout(initSplit, 300);
};
const setupSyncScroll = () => {
let isScrolling = false;
const onScroll = (src, tgt) => {
if (!syncScrollEnabled.value || isScrolling) return;
const pct = src.scrollTop / (src.scrollHeight - src.clientHeight);
tgt.scrollTop = pct * (tgt.scrollHeight - tgt.clientHeight);
isScrolling = true;
requestAnimationFrame(() => isScrolling = false);
};
if (originalPane.value) originalPane.value.onscroll = () => {
if (translatedFrame.value && translatedFrame.value.contentWindow)
onScroll(originalPane.value, translatedFrame.value.contentWindow.document.documentElement);
};
if (translatedFrame.value) translatedFrame.value.onload = () => {
const win = translatedFrame.value.contentWindow;
if (win) win.onscroll = () => onScroll(win.document.documentElement, originalPane.value);
};
};
const setPreviewMode = (m) => {
previewMode.value = m;
setTimeout(initSplit, 100);
};
const toggleSyncScroll = () => {
syncScrollEnabled.value = !syncScrollEnabled.value;
localStorage.setItem('ui_sync_scroll_enabled', syncScrollEnabled.value);
};
const printPdf = (url) => {
const msg = t('pdf_preparing') || "正在准备打印,请稍候...";
const toastContainer = document.createElement('div');
toastContainer.className = 'toast-container position-fixed top-0 start-50 translate-middle-x p-3';
toastContainer.style.zIndex = '1090';
toastContainer.innerHTML = `
<div class="toast align-items-center text-bg-primary border-0 fade show" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-printer-fill me-2"></i>${msg}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
document.body.appendChild(toastContainer);
setTimeout(() => {
const t = toastContainer.querySelector('.toast');
if (t) {
t.classList.remove('show');
setTimeout(() => {
if (toastContainer.parentNode) toastContainer.remove();
}, 500);
}
}, 3000);
const ifr = document.getElementById('printFrame');
fetch(url).then(r => r.text()).then(h => {
ifr.srcdoc = h;
ifr.onload = () => {
setTimeout(() => {
ifr.contentWindow.focus();
ifr.contentWindow.print();
}, 500);
};
});
};
const copyLog = (e, l) => {
navigator.clipboard.writeText(l.replace(/<br>/g, '\n')).then(() => {
const btn = e.currentTarget;
const icon = btn.querySelector('i');
btn.classList.replace('btn-outline-secondary', 'btn-success');
icon.classList.replace('bi-clipboard', 'bi-check-lg');
setTimeout(() => {
btn.classList.replace('btn-success', 'btn-outline-secondary');
icon.classList.replace('bi-check-lg', 'bi-clipboard');
}, 2000);
});
};
const getFileIcon = (t) => ({
markdown: 'bi-markdown',
markdown_zip: 'bi-file-zip',
docx: 'bi-filetype-docx',
json: 'bi-filetype-json',
txt: 'bi-filetype-txt',
xlsx: 'bi-filetype-xlsx',
csv: 'bi-filetype-csv',
srt: 'bi-file-text',
epub: 'bi-book',
ass: 'bi-file-easel',
html: 'bi-filetype-html',
pptx: 'bi-file-slides'
}[t] || 'bi-file-earmark');
const exportConfig = () => {
const config = {form: form, workflowParams: workflowParams};
const blob = new Blob([JSON.stringify(config, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'config.json';
a.click();
setTimeout(() => {
URL.revokeObjectURL(url);
}, 100);
};
const importConfig = (e) => {
const f = e.target.files[0];
if (f) {
const r = new FileReader();
r.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
if (data.form) Object.assign(form, data.form);
if (data.workflowParams) Object.assign(workflowParams, data.workflowParams);
['platform', 'base_url', 'api_key', 'model_id', 'provider'].forEach((key) => {
if (key in form) delete form[key];
});
syncModelPresetSelection();
saveAllSettings();
alert(t('configImportSuccess'));
} catch (err) {
alert(t('configImportError'));
}
};
r.readAsText(f);
}
e.target.value = '';
};
const setLang = (l) => {
currentLang.value = l;
localStorage.setItem('ui_language', l);
document.documentElement.lang = l === 'zh' ? 'zh-CN' : 'en';
};
const setTheme = (t) => {
localStorage.setItem('theme', t);
if (t === 'auto') document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
else document.documentElement.setAttribute('data-bs-theme', t);
};
onMounted(async () => {
// I18n
try {
const res = await fetch("/static/i18nData.json");
i18nData.value = await res.json();
// Add new missing translations for Mineru Deploy
const extraZh = {
mineruDeployParseMethodLabel: "解析方法 (Parse Method)",
mineruDeployTableEnableLabel: "表格识别 (Table Recognition)"
};
const extraEn = {
mineruDeployParseMethodLabel: "Parse Method",
mineruDeployTableEnableLabel: "Table Recognition"
};
if(i18nData.value.zh) Object.assign(i18nData.value.zh, extraZh);
if(i18nData.value.en) Object.assign(i18nData.value.en, extraEn);
} catch (e) {
i18nData.value = {
zh: {
pageTitle: "DocuTranslate",
tutorialBtn: "教程",
projectContributeBtn: "项目协作",
workflowTitle: "选择工作流",
autoWorkflowLabel: "自动选择工作流",
modelPresetLabel: "模型预设",
modelPresetPlaceholder: "请选择模型预设",
modelPresetEmpty: "请先在服务端环境变量中配置模型预设",
modelPresetRuntimeHint: "运行时将从服务端环境变量读取供应商、模型端点与 API Key。",
workflowOptionPptx: "PPTX 演示文稿",
pptxSettingsTitleText: "PPTX 设置",
mineruDeployServerUrlLabel: "Server URL",
mineruDeployLangListLabel: "语言列表 (Pipeline模式)",
mineruDeployServerUrlPlaceholder: "http://127.0.0.1:30000",
mineruDeployParseMethodLabel: "解析方法 (Parse Method)",
mineruDeployTableEnableLabel: "表格识别 (Table Recognition)"
},
en: {
pageTitle: "DocuTranslate",
tutorialBtn: "Tutorial",
projectContributeBtn: "Contribute",
workflowTitle: "Select Workflow",
modelPresetLabel: "Model Preset",
modelPresetPlaceholder: "Select a model preset",
modelPresetEmpty: "Configure model presets in server environment variables first",
modelPresetRuntimeHint: "Provider, endpoint, and API key will be loaded from server environment variables at runtime.",
workflowOptionPptx: "PPTX Presentation",
pptxSettingsTitleText: "PPTX Settings",
mineruDeployServerUrlLabel: "Server URL",
mineruDeployLangListLabel: "Language List (Pipeline Mode)",
mineruDeployServerUrlPlaceholder: "http://127.0.0.1:30000",
mineruDeployParseMethodLabel: "Parse Method",
mineruDeployTableEnableLabel: "Table Recognition"
}
};
}
// Backend Metadata
try {
const [metaRes, enginRes, paramsRes, configRes] = await Promise.all([
fetch("/service/meta"), fetch('/service/engin-list'), fetch("/service/default-params"),
fetch("/api/config")
]);
const meta = await metaRes.json();
version.value = meta.version;
enginList.value = await enginRes.json();
Object.assign(defaultParams, await paramsRes.json());
const envConfig = await configRes.json().catch(() => ({}));
modelPresets.value = Array.isArray(envConfig.model_presets) ? envConfig.model_presets : [];
defaultModelPreset.value = envConfig.default_model_preset || (modelPresets.value[0]?.id || '');
if (defaultModelPreset.value && !localStorage.getItem('translator_model_preset')) {
localStorage.setItem('translator_model_preset', defaultModelPreset.value);
}
if (envConfig.rpm != null && !localStorage.getItem('rpm')) {
localStorage.setItem('rpm', String(envConfig.rpm));
}
if (envConfig.tpm != null && !localStorage.getItem('tpm')) {
localStorage.setItem('tpm', String(envConfig.tpm));
}
} catch (e) {
console.error("Backend init failed", e);
}
loadConfig();
setTheme(localStorage.getItem('theme') || 'auto');
// Restore tasks
if (window.location.pathname.includes('/admin')) {
document.title = "DocuTranslate - Admin Panel";
try {
const r = await fetch('/service/task-list');
const ids = await r.json();
if (ids) ids.reverse().forEach(id => createNewTask(id));
} catch (e) {
}
} else {
const savedIds = JSON.parse(localStorage.getItem('active_task_ids') || '[]');
if (savedIds.length) savedIds.forEach(id => createNewTask(id));
else createNewTask();
}
new bootstrap.Tooltip(document.body, {selector: '[data-bs-toggle="tooltip"]'});
// Global resize handler for preview
window.addEventListener('resize', () => {
if (document.getElementById('previewOffcanvas').classList.contains('show')) {
initSplit();
}
});
});
return {
version, currentLang, i18nData, glossaryData, glossaryCount, tasks, enginList, defaultParams,
modelPresets,
form, workflowParams, showMineruToken, previewMode, syncScrollEnabled, showIdentityOption,
errors, clearError,
t, createNewTask, removeTask, handleTaskFileSelect, handleTaskFileDrop, toggleTaskState,
handleGlossaryFiles, clearGlossary, openGlossaryModal,
openPreview, setPreviewMode, toggleSyncScroll, printPdf, copyLog, getFileIcon,
exportConfig, importConfig, setLang, setTheme, capitalize,
currentWorkflowConfig, ocrOptions, stepMap,
originalPane, translatedFrame, splitInstance, splitContainer, previewTask,
saveSetting, saveWorkflowParam, saveSettingArray,
glossaryInput, configFile, triggerFileInput,
mineruLangOptions // Export new constant
};
}
}).mount('#app');
</script>
</body>
</html>