2011 lines
112 KiB
HTML
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, "<").replace(/>/g, ">")).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>
|