Files
docutranslate/docutranslate/static/index.html
r-earth-or 9d8eacf0b4 feat:前端不显示模型api-key
隐藏GitHub链接
2026-04-15 13:57:05 +08:00

2011 lines
112 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuTranslate - 交互式文档翻译</title>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<!-- Bootstrap CSS -->
<link href="/static/bootstrap.css" rel="stylesheet" crossorigin="anonymous">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="/static/bootstrap-icons.css">
<style>
/* 复用原版 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>