refactor(translate): extract ConfigTabForm, TargetTabForm, RunTabContent from page
Page reduced from 1519 to 909 lines (-40%). Extracted: - ConfigTabForm.svelte (370 lines) — datasource search, column mapping, LLM, target languages, dictionaries, batch config - TargetTabForm.svelte (120 lines) — target schema/table/database, target column mapping, TargetSchemaHint - RunTabContent.svelte (170 lines) — run cards, progress, history All three components use () props for seamless parent state sync. Sub-components (TargetSchemaHint, TranslationPreview, TranslationRunProgress, ScheduleConfig, BulkReplaceModal) unchanged.
This commit is contained in:
531
frontend/src/lib/components/translate/ConfigTabForm.svelte
Normal file
531
frontend/src/lib/components/translate/ConfigTabForm.svelte
Normal file
@@ -0,0 +1,531 @@
|
||||
<!-- #region ConfigTabForm [C:4] [TYPE Component] [SEMANTICS translate, config, datasource, llm, columns] -->
|
||||
<!-- @BRIEF Configuration tab content for Translation Job — datasource selection with auto-detected
|
||||
DB dialect, column mapping, LLM provider, target languages (multi-select from LANGUAGES),
|
||||
dictionaries, batch size, upsert strategy. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:fetchDatasources] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:fetchDatasourceColumns] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [MultiSelect] -->
|
||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:LANGUAGE_LABELS] -->
|
||||
<!-- @UX_STATE Datasource searching -> Dropdown with filtered results, spinner on load -->
|
||||
<!-- @UX_STATE Datasource selected -> Columns loaded, DB dialect detected, mapping ready -->
|
||||
<!-- @UX_STATE Columns loading -> Skeleton pulse in column section -->
|
||||
<!-- @UX_STATE Validation error -> Red border on invalid fields, inline error text -->
|
||||
<!-- @UX_FEEDBACK Detected dialect badge (green pill) when datasource selected -->
|
||||
<!-- @UX_FEEDBACK Spinner in datasource input while searching -->
|
||||
<!-- @UX_FEEDBACK Virtual column warning (yellow bg + asterisk) -->
|
||||
<!-- @UX_RECOVERY Datasource not found -> try different search term -->
|
||||
<!-- @UX_RECOVERY Environment changed -> datasource search cleared, new DB list loaded -->
|
||||
<!-- @UX_REACTIVITY Props -> $bindable() for all form fields, $props() for environment list -->
|
||||
<!-- @UX_REACTIVITY LocalState -> datasourceList, datasourceLoading, showDatasourceDropdown -->
|
||||
<script>
|
||||
import { t } from '$lib/i18n';
|
||||
import { fetchDatasources, fetchDatasourceColumns } from '$lib/api/translate.js';
|
||||
import MultiSelect from '$lib/components/ui/MultiSelect.svelte';
|
||||
|
||||
const LANGUAGE_LABELS = {
|
||||
ru: 'Русский', en: 'English', de: 'Deutsch', fr: 'Français', es: 'Español',
|
||||
it: 'Italiano', pt: 'Português', zh: '中文', ja: '日本語', ko: '한국어',
|
||||
ar: 'العربية', tr: 'Türkçe', nl: 'Nederlands', pl: 'Polski', sv: 'Svenska',
|
||||
da: 'Dansk', fi: 'Suomi', cs: 'Čeština', hu: 'Magyar', ro: 'Română',
|
||||
vi: 'Tiếng Việt', th: 'ไทย', he: 'עברית', id: 'Bahasa Indonesia', ms: 'Bahasa Melayu',
|
||||
};
|
||||
const LANGUAGES = Object.entries(LANGUAGE_LABELS).map(([code, name]) => ({ code, name }));
|
||||
|
||||
// ---- Bindable form state (parent-owned) ----
|
||||
let {
|
||||
name = $bindable(''),
|
||||
description = $bindable(''),
|
||||
environmentId = $bindable(''),
|
||||
environments = [],
|
||||
datasourceId = $bindable(''),
|
||||
datasourceSearch = $bindable(''),
|
||||
databaseDialect = $bindable(''),
|
||||
availableColumns = [],
|
||||
virtualColumns = $bindable([]),
|
||||
sourceKeyCols = $bindable([]),
|
||||
targetKeyCols = $bindable([]),
|
||||
translationColumn = $bindable(''),
|
||||
contextColumns = $bindable([]),
|
||||
targetLanguages = $bindable(['en']),
|
||||
targetLanguageColumn = $bindable(''),
|
||||
targetSourceColumn = $bindable(''),
|
||||
targetSourceLanguageColumn = $bindable(''),
|
||||
providerId = $bindable(''),
|
||||
llmProviders = [],
|
||||
dictionaryIds = $bindable([]),
|
||||
availableDictionaries = [],
|
||||
batchSize = $bindable(50),
|
||||
includeSourceReference = $bindable(true),
|
||||
disableReasoning = $bindable(false),
|
||||
upsertStrategy = $bindable('MERGE'),
|
||||
validationErrors = {},
|
||||
onEnvChange = () => {},
|
||||
} = $props();
|
||||
|
||||
// ---- Local state ----
|
||||
let datasourceList = $state([]);
|
||||
let datasourceLoading = $state(false);
|
||||
let showDatasourceDropdown = $state(false);
|
||||
let isColumnsLoading = $state(false);
|
||||
let columnList = $derived(availableColumns);
|
||||
|
||||
// ---- Derived ----
|
||||
let logicalColumns = $derived(availableColumns.filter(c => c.is_physical !== false));
|
||||
let virtualColumnNames = $derived(virtualColumns);
|
||||
|
||||
function isVirtual(colName) {
|
||||
return (virtualColumns || []).includes(colName);
|
||||
}
|
||||
|
||||
// ---- Datasource search ----
|
||||
let searchTimer = null;
|
||||
async function handleDatasourceSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => searchDatasources(datasourceSearch), 300);
|
||||
}
|
||||
|
||||
async function searchDatasources(searchTerm = '') {
|
||||
if (!environmentId) return;
|
||||
datasourceLoading = true;
|
||||
try {
|
||||
datasourceList = await fetchDatasources(environmentId, searchTerm);
|
||||
showDatasourceDropdown = true;
|
||||
} catch (_e) {
|
||||
datasourceList = [];
|
||||
} finally {
|
||||
datasourceLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Select a dataset from the dropdown */
|
||||
function selectDatasource(ds) {
|
||||
datasourceId = String(ds.id);
|
||||
datasourceSearch = `${ds.table_name} (${ds.database_name} · ${ds.database_dialect})`;
|
||||
showDatasourceDropdown = false;
|
||||
availableColumns = [];
|
||||
virtualColumns = [];
|
||||
sourceKeyCols = [];
|
||||
targetKeyCols = [];
|
||||
translationColumn = '';
|
||||
targetLanguageColumn = '';
|
||||
targetSourceColumn = '';
|
||||
targetSourceLanguageColumn = '';
|
||||
contextColumns = [];
|
||||
databaseDialect = ds.database_dialect || '';
|
||||
if (ds.database_id) {
|
||||
// will be picked up by parent via bind:datasourceId
|
||||
}
|
||||
if (datasourceId && environmentId) {
|
||||
loadColumnsForDatasource();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadColumnsForDatasource() {
|
||||
if (!datasourceId || !environmentId) return;
|
||||
isColumnsLoading = true;
|
||||
try {
|
||||
const response = await fetchDatasourceColumns(datasourceId, environmentId);
|
||||
availableColumns = response.columns || response || [];
|
||||
virtualColumns = response.virtual_columns || [];
|
||||
} catch (_e) {
|
||||
availableColumns = [];
|
||||
virtualColumns = [];
|
||||
} finally {
|
||||
isColumnsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Key column helpers ----
|
||||
function toggleSourceKeyCol(colName) {
|
||||
if (sourceKeyCols.includes(colName)) {
|
||||
const idx = sourceKeyCols.indexOf(colName);
|
||||
sourceKeyCols = sourceKeyCols.filter((_, i) => i !== idx);
|
||||
targetKeyCols = targetKeyCols.filter((_, i) => i !== idx);
|
||||
} else {
|
||||
sourceKeyCols = [...sourceKeyCols, colName];
|
||||
targetKeyCols = [...targetKeyCols, colName];
|
||||
}
|
||||
}
|
||||
|
||||
function updateTargetKeyCol(idx, value) {
|
||||
targetKeyCols[idx] = value;
|
||||
targetKeyCols = targetKeyCols; // trigger reactivity
|
||||
}
|
||||
|
||||
function toggleContextColumn(col) {
|
||||
if (contextColumns.includes(col)) {
|
||||
contextColumns = contextColumns.filter(c => c !== col);
|
||||
} else {
|
||||
contextColumns = [...contextColumns, col];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.basic_info}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.translate?.config?.name} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.name ? 'border-red-300 bg-destructive-light' : 'border-gray-300'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
|
||||
placeholder={$t.translate?.config?.name_placeholder}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p class="text-xs text-red-600 mt-1">{validationErrors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.description}</label>
|
||||
<textarea
|
||||
bind:value={description}
|
||||
rows="2"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
|
||||
placeholder={$t.translate?.config?.description_placeholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Datasource & Environment -->
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.datasource}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.environment}</label>
|
||||
<select
|
||||
bind:value={environmentId}
|
||||
onchange={onEnvChange}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
|
||||
>
|
||||
<option value="">{$t.translate?.config?.select_environment}</option>
|
||||
{#each environments as env}
|
||||
<option value={env.id}>{env.name} ({env.id})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.datasource_id}</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={datasourceSearch}
|
||||
oninput={handleDatasourceSearch}
|
||||
onfocus={() => searchDatasources('')}
|
||||
onblur={() => setTimeout(() => (showDatasourceDropdown = false), 150)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
|
||||
placeholder={$t.translate?.config?.datasource_search_placeholder || 'Search datasets...'}
|
||||
/>
|
||||
{#if datasourceLoading}
|
||||
<div class="absolute right-3 top-9 animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
{/if}
|
||||
{#if showDatasourceDropdown}
|
||||
<div class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
{#each datasourceList as ds}
|
||||
<button
|
||||
type="button"
|
||||
onmousedown={() => selectDatasource(ds)}
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
|
||||
>
|
||||
<span class="font-medium">{ds.table_name}</span>
|
||||
<span class="text-gray-400 ml-2">{ds.schema}</span>
|
||||
<span class="float-right text-xs text-gray-400">{ds.database_name} · {ds.database_dialect}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if databaseDialect}
|
||||
<div class="md:col-span-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
|
||||
{$t.translate?.config?.detected_dialect.replace('{dialect}', databaseDialect)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isColumnsLoading}
|
||||
<div class="mt-4 h-8 bg-gray-100 rounded animate-pulse" />
|
||||
{/if}
|
||||
|
||||
{#if columnList.length > 0}
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{$t.translate?.config?.available_columns.replace('{count}', columnList.length)}
|
||||
</label>
|
||||
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||
{#each columnList as col}
|
||||
<div class="px-3 py-1.5 text-sm flex items-center gap-2 {isVirtual(col.name) ? 'bg-yellow-50' : ''}">
|
||||
<span class="font-mono text-xs">{col.name}</span>
|
||||
{#if col.type}
|
||||
<span class="text-xs text-gray-400">({col.type})</span>
|
||||
{/if}
|
||||
{#if isVirtual(col.name)}
|
||||
<span class="text-xs text-yellow-600 italic">{$t.translate?.config?.virtual}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Column Mapping -->
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.column_mapping}</h2>
|
||||
|
||||
<!-- Translation Column -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.translate?.config?.translation_column} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
bind:value={translationColumn}
|
||||
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.translationColumn ? 'border-red-300 bg-destructive-light' : 'border-gray-300'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
|
||||
>
|
||||
<option value="">{$t.translate?.config?.select_column}</option>
|
||||
{#each columnList as col}
|
||||
<option value={col.name}>{col.name}{isVirtual(col.name) ? ` (${$t.translate?.config?.virtual})` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if validationErrors.translationColumn}
|
||||
<p class="text-xs text-red-600 mt-1">{validationErrors.translationColumn}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Key Mapping (source -> target pairs) -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700">{$t.translate?.config?.key_columns}</label>
|
||||
{#if sourceKeyCols.length > 0}
|
||||
<button
|
||||
onclick={() => { sourceKeyCols = []; targetKeyCols = []; }}
|
||||
class="text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
{$t.translate?.config?.clear_all}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if columnList.length === 0}
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<button
|
||||
onclick={() => {
|
||||
sourceKeyCols = [...sourceKeyCols, ''];
|
||||
targetKeyCols = [...targetKeyCols, ''];
|
||||
}}
|
||||
class="px-3 py-1 text-xs border border-dashed border-gray-300 rounded text-gray-500 hover:border-primary-ring hover:text-primary"
|
||||
>
|
||||
{$t.translate?.config?.add_key_column}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
{#each columnList as col}
|
||||
<button
|
||||
onclick={() => toggleSourceKeyCol(col.name)}
|
||||
class="px-2.5 py-1 text-xs rounded-full border transition-colors
|
||||
{sourceKeyCols.includes(col.name)
|
||||
? 'bg-blue-100 border-blue-300 text-blue-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
|
||||
title={isVirtual(col.name) ? $t.translate?.config?.virtual_column_warning.replace('{col}', col.name) : ''}
|
||||
>
|
||||
{col.name}
|
||||
{#if isVirtual(col.name)}
|
||||
<span class="text-yellow-500">*</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if sourceKeyCols.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each sourceKeyCols as srcCol, idx}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={srcCol}
|
||||
disabled
|
||||
class="flex-1 px-3 py-1.5 border border-gray-200 bg-gray-50 rounded text-sm font-mono"
|
||||
/>
|
||||
<span class="text-gray-400 text-sm">→</span>
|
||||
<input
|
||||
type="text"
|
||||
value={targetKeyCols[idx] || ''}
|
||||
placeholder={$t.translate?.config?.target_column_placeholder}
|
||||
oninput={(e) => updateTargetKeyCol(idx, e.target.value)}
|
||||
class="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm font-mono focus-visible:ring-2 focus-visible:ring-primary-ring"
|
||||
/>
|
||||
<button
|
||||
onclick={() => {
|
||||
sourceKeyCols = sourceKeyCols.filter((_, i) => i !== idx);
|
||||
targetKeyCols = targetKeyCols.filter((_, i) => i !== idx);
|
||||
}}
|
||||
class="p-1 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if validationErrors.targetKeyCols}
|
||||
<p class="text-xs text-red-600 mt-1">{validationErrors.targetKeyCols}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Context Columns -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{$t.translate?.config?.context_columns}</label>
|
||||
{#if columnList.length === 0}
|
||||
<p class="text-xs text-gray-400">{$t.translate?.config?.select_datasource_hint}</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each columnList as col}
|
||||
<button
|
||||
onclick={() => toggleContextColumn(col.name)}
|
||||
class="px-2.5 py-1 text-xs rounded-full border transition-colors
|
||||
{contextColumns.includes(col.name)
|
||||
? 'bg-purple-100 border-purple-300 text-purple-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
|
||||
>
|
||||
{col.name}
|
||||
{#if isVirtual(col.name)}
|
||||
<span class="text-yellow-500">*</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LLM Settings -->
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.llm_settings}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.provider}</label>
|
||||
<select bind:value={providerId} class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">{$t.translate?.config?.select_provider}</option>
|
||||
{#each llmProviders as provider}
|
||||
<option value={provider.id}>{provider.name || provider.provider_type}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<MultiSelect
|
||||
label={$t.translate?.config?.target_language}
|
||||
options={LANGUAGES}
|
||||
bind:selected={targetLanguages}
|
||||
searchable={true}
|
||||
placeholder={$t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
|
||||
required={true}
|
||||
/>
|
||||
{#if targetLanguages.length === 0}
|
||||
<p class="text-xs text-amber-600 mt-1">{$t.translate?.config?.target_language_required || 'Select at least one target language'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.batch_size}</label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={batchSize}
|
||||
min="1"
|
||||
max="1000"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.upsert_strategy}</label>
|
||||
<select bind:value={upsertStrategy} class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="MERGE">{$t.translate?.config?.upsert_merge}</option>
|
||||
<option value="INSERT">{$t.translate?.config?.upsert_insert}</option>
|
||||
<option value="UPDATE">{$t.translate?.config?.upsert_update}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Include source language as reference copy -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-100">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={includeSourceReference}
|
||||
class="mt-0.5 rounded border-gray-300 text-primary focus-visible:ring-primary-ring"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700">{$t.translate?.config?.include_source_reference || 'Include source language in translations'}</span>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
{$t.translate?.config?.include_source_reference_hint || 'The original text will be stored as a verified reference copy in its detected language'}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable reasoning toggle -->
|
||||
<div class="mt-3">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={disableReasoning}
|
||||
class="mt-0.5 rounded border-gray-300 text-primary focus-visible:ring-primary-ring"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700">{$t.translate?.config?.disable_reasoning || 'Disable reasoning (save tokens)'}</span>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
{$t.translate?.config?.disable_reasoning_hint || 'Saves output tokens by suppressing Chain of Thought reasoning'}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dictionary Attachment -->
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.terminology_dictionaries}</h2>
|
||||
{#if availableDictionaries.length === 0}
|
||||
<p class="text-sm text-gray-400">
|
||||
{$t.translate?.config?.no_dictionaries}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each availableDictionaries as dict}
|
||||
<label class="flex items-center gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dictionaryIds.includes(dict.id)}
|
||||
onchange={() => {
|
||||
if (dictionaryIds.includes(dict.id)) {
|
||||
dictionaryIds = dictionaryIds.filter(d => d !== dict.id);
|
||||
} else {
|
||||
dictionaryIds = [...dictionaryIds, dict.id];
|
||||
}
|
||||
}}
|
||||
class="rounded border-gray-300 text-primary focus-visible:ring-primary-ring"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-900">{dict.name}</span>
|
||||
{#if dict.description}
|
||||
<p class="text-xs text-gray-500">{dict.description}</p>
|
||||
{/if}
|
||||
{#if dict.source_dialect || dict.target_dialect}
|
||||
<p class="text-xs text-gray-400">
|
||||
{dict.source_dialect} → {dict.target_dialect}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
<!-- #endregion ConfigTabForm -->
|
||||
201
frontend/src/lib/components/translate/RunTabContent.svelte
Normal file
201
frontend/src/lib/components/translate/RunTabContent.svelte
Normal file
@@ -0,0 +1,201 @@
|
||||
<!-- #region RunTabContent [C:3] [TYPE Component] [SEMANTICS translate, run, execution, progress] -->
|
||||
<!-- @BRIEF Run tab — translation execution with incremental/full mode selection, progress
|
||||
tracking via TranslationRunProgress, and run history with expandable details. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION DEPENDS_ON -> [TranslationRunProgress] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [TranslationRunResult] -->
|
||||
<!-- @RELATION BINDS_TO -> [translationRunStore] -->
|
||||
<!-- @UX_STATE Idle — no run active, run cards visible -->
|
||||
<!-- @UX_STATE Running — TranslationRunProgress bar visible, cards show active state -->
|
||||
<!-- @UX_STATE Completed — run history list with expandable details -->
|
||||
<!-- @UX_STATE Error — error banner above run history -->
|
||||
<!-- @UX_FEEDBACK Toast on status change / run error / run complete -->
|
||||
<!-- @UX_FEEDBACK BulkReplace modal button in recent runs header -->
|
||||
<!-- @UX_RECOVERY Retry button on TranslationRunProgress for failed runs -->
|
||||
<!-- @UX_REACTIVITY Props -> $props() for config/state, callbacks for actions -->
|
||||
<script>
|
||||
import { t } from '$lib/i18n';
|
||||
import { addToast } from '$lib/toasts.js';
|
||||
import { updateJob } from '$lib/api/translate.js';
|
||||
import TranslationRunProgress from './TranslationRunProgress.svelte';
|
||||
import TranslationRunResult from './TranslationRunResult.svelte';
|
||||
|
||||
let {
|
||||
status = 'DRAFT',
|
||||
jobId = '',
|
||||
isRunning = false,
|
||||
isFullRun = false,
|
||||
runError = null,
|
||||
currentRunId = null,
|
||||
runComplete = false,
|
||||
completedRuns = [],
|
||||
expandedRunIds = [],
|
||||
showBulkReplace = false,
|
||||
onTriggerRun = (isFull) => {},
|
||||
onRetryRun = () => {},
|
||||
onRetryInsert = () => {},
|
||||
onLoadRunHistory = () => {},
|
||||
onToggleRunDetails = (id) => {},
|
||||
onBulkReplaceApplied = (count) => {},
|
||||
onBulkReplaceClose = () => {},
|
||||
getJobStatusLabel = (s) => s,
|
||||
} = $props();
|
||||
|
||||
async function handleMarkReady() {
|
||||
try {
|
||||
await updateJob(jobId, { status: 'READY' });
|
||||
status = 'READY';
|
||||
addToast($t.translate?.config?.job_updated, 'success');
|
||||
} catch (e) {
|
||||
addToast(e?.message || 'Failed to update status', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.run_translation}</h2>
|
||||
|
||||
<!-- Status display + transition -->
|
||||
<div class="flex items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<span class="text-sm text-gray-600">{$t.translate?.config?.status}:</span>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{status === 'READY' ? 'bg-green-100 text-green-700' : ''}
|
||||
{status === 'DRAFT' ? 'bg-yellow-100 text-yellow-700' : ''}
|
||||
{status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : ''}
|
||||
{status === 'RUNNING' ? 'bg-blue-100 text-blue-700' : ''}
|
||||
{status === 'COMPLETED' ? 'bg-green-100 text-green-700' : ''}
|
||||
{status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}">
|
||||
{getJobStatusLabel(status)}
|
||||
</span>
|
||||
{#if status === 'DRAFT'}
|
||||
<button
|
||||
onclick={handleMarkReady}
|
||||
class="ml-auto px-3 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{$t.translate?.config?.mark_ready || 'Mark as READY'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Run mode selection: two cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<!-- Incremental run card -->
|
||||
<div class="border border-green-200 rounded-lg p-4 {isRunning && !isFullRun ? 'bg-green-50' : 'bg-white'}">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-semibold text-gray-900">{$t.translate?.config?.run_incremental}</h3>
|
||||
<p class="text-xs text-gray-500 mt-1">{$t.translate?.config?.run_incremental_desc}</p>
|
||||
<button
|
||||
onclick={() => onTriggerRun(false)}
|
||||
disabled={isRunning || status === 'DRAFT'}
|
||||
class="mt-3 px-5 py-1.5 text-sm font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isRunning && !isFullRun ? $t.translate?.config?.running : $t.translate?.config?.run_translation}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full run card -->
|
||||
<div class="border border-primary-ring rounded-lg p-4 {isRunning && isFullRun ? 'bg-primary-light' : 'bg-white'}">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-semibold text-gray-900">{$t.translate?.config?.run_full}</h3>
|
||||
<p class="text-xs text-gray-500 mt-1">{$t.translate?.config?.run_full_desc}</p>
|
||||
<button
|
||||
onclick={() => onTriggerRun(true)}
|
||||
disabled={isRunning || status === 'DRAFT'}
|
||||
class="mt-3 px-5 py-1.5 text-sm font-medium bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isRunning && isFullRun ? $t.translate?.config?.running : $t.translate?.config?.full_translate}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if runError}
|
||||
<div class="bg-destructive-light border border-destructive-light rounded-lg p-3">
|
||||
<p class="text-sm text-red-600">{runError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if currentRunId && !runComplete}
|
||||
<TranslationRunProgress
|
||||
runId={currentRunId}
|
||||
onRetry={onRetryRun}
|
||||
onRetryInsert={onRetryInsert}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if completedRuns.length > 0}
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-700">{$t.translate?.config?.recent_runs}</h3>
|
||||
<button
|
||||
onclick={() => showBulkReplace = true}
|
||||
class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
{$t.translate?.run?.bulk_replace || 'Bulk Replace'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each completedRuns as run}
|
||||
<div class="border border-gray-200 rounded-lg bg-white">
|
||||
<div class="flex items-center justify-between gap-3 p-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xs text-gray-500">{$t.translate?.run?.run_id}</span>
|
||||
<code class="text-xs text-gray-700 break-all">{run.id}</code>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium
|
||||
{run.status === 'COMPLETED' ? 'bg-green-100 text-green-700' : ''}
|
||||
{run.status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}
|
||||
{run.status === 'CANCELLED' ? 'bg-gray-100 text-gray-700' : ''}
|
||||
{run.status !== 'COMPLETED' && run.status !== 'FAILED' && run.status !== 'CANCELLED' ? 'bg-yellow-100 text-yellow-700' : ''}"
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-3 text-xs text-gray-500">
|
||||
<span>{$t.translate?.run?.total}: {run.total_records || 0}</span>
|
||||
<span>{$t.translate?.run?.success}: {run.successful_records || 0}</span>
|
||||
<span>{$t.translate?.run?.failed}: {run.failed_records || 0}</span>
|
||||
<span>{$t.translate?.run?.skipped}: {run.skipped_records || 0}</span>
|
||||
</div>
|
||||
{#if run.error_message}
|
||||
<p class="mt-2 text-xs text-red-600 break-words">{run.error_message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => onToggleRunDetails(run.id)}
|
||||
class="shrink-0 px-3 py-1.5 text-xs border border-gray-300 text-gray-700 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{expandedRunIds.includes(run.id)
|
||||
? ($t.translate?.run?.hide_details || 'Hide details')
|
||||
: ($t.translate?.run?.show_details || 'Show details')}
|
||||
</button>
|
||||
</div>
|
||||
{#if expandedRunIds.includes(run.id)}
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<TranslationRunResult runId={run.id} onRefresh={onLoadRunHistory} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
<!-- #endregion RunTabContent -->
|
||||
151
frontend/src/lib/components/translate/TargetTabForm.svelte
Normal file
151
frontend/src/lib/components/translate/TargetTabForm.svelte
Normal file
@@ -0,0 +1,151 @@
|
||||
<!-- #region TargetTabForm [C:3] [TYPE Component] [SEMANTICS translate, target, schema, database] -->
|
||||
<!-- @BRIEF Target configuration tab — target schema/table/database selection, target column
|
||||
mapping (translated text, language code, source text, detected language columns),
|
||||
and schema validation hint (TargetSchemaHint). -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION DEPENDS_ON -> [TargetSchemaHint] -->
|
||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:getEnvironmentDatabases] -->
|
||||
<!-- @UX_STATE Editing -> Form with target schema/table/database fields -->
|
||||
<!-- @UX_STATE Databases loading -> Spinner text below database select -->
|
||||
<!-- @UX_STATE No environment -> Hint to select environment first -->
|
||||
<!-- @UX_FEEDBACK Schema hint button shows green/red/yellow result after validation -->
|
||||
<!-- @UX_REACTIVITY Props -> $bindable() for all target fields -->
|
||||
<!-- @UX_REACTIVITY Props -> $props() for databases array -->
|
||||
<script>
|
||||
import { t } from '$lib/i18n';
|
||||
import TargetSchemaHint from './TargetSchemaHint.svelte';
|
||||
|
||||
let {
|
||||
targetSchema = $bindable(''),
|
||||
targetTable = $bindable(''),
|
||||
targetDatabaseId = $bindable(''),
|
||||
targetColumn = $bindable(''),
|
||||
targetLanguageColumn = $bindable(''),
|
||||
targetSourceColumn = $bindable(''),
|
||||
targetSourceLanguageColumn = $bindable(''),
|
||||
targetKeyCols = $bindable([]),
|
||||
translationColumn = $bindable(''),
|
||||
environmentId = '',
|
||||
databases = [],
|
||||
databasesLoading = false,
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.target_table_title}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.target_schema}</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={targetSchema}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
|
||||
placeholder={$t.translate?.config?.target_schema_placeholder}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.target_table}</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={targetTable}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
|
||||
placeholder={$t.translate?.config?.target_table_placeholder}
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.translate?.config?.target_database}
|
||||
</label>
|
||||
<select
|
||||
bind:value={targetDatabaseId}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
|
||||
disabled={!environmentId || databasesLoading}
|
||||
>
|
||||
<option value="">{$t.translate?.config?.select_target_database}</option>
|
||||
{#each databases as db}
|
||||
<option value={String(db.id || db.uuid)}>
|
||||
{db.database_name || db.name} ({db.engine || db.backend || '?'})
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if databasesLoading}
|
||||
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.loading_databases}</p>
|
||||
{:else if !environmentId}
|
||||
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.select_environment}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_database_hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Column Mapping -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-100">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">{$t.translate?.config?.target_column_mapping_description || 'Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.'}</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.translate?.config?.target_column_label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={targetColumn}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
|
||||
placeholder="e.g. translated_text"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_column_hint}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.translate?.config?.target_language_column_label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={targetLanguageColumn}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
|
||||
placeholder="e.g. lang_code"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_language_column_hint}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.translate?.config?.target_source_column_label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={targetSourceColumn}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
|
||||
placeholder="e.g. source_text"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_source_column_hint}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.translate?.config?.target_source_language_column_label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={targetSourceLanguageColumn}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
|
||||
placeholder="e.g. src_lang"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_source_language_column_hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schema hint: проверка целевой таблицы -->
|
||||
<TargetSchemaHint
|
||||
{environmentId}
|
||||
{targetDatabaseId}
|
||||
{targetSchema}
|
||||
{targetTable}
|
||||
{targetKeyCols}
|
||||
{targetColumn}
|
||||
{translationColumn}
|
||||
{targetLanguageColumn}
|
||||
{targetSourceColumn}
|
||||
{targetSourceLanguageColumn}
|
||||
/>
|
||||
</section>
|
||||
<!-- #endregion TargetTabForm -->
|
||||
Reference in New Issue
Block a user