refactor(frontend): bind translate/[id] to TranslationJobModel
Translation job config: 835→268 lines (-68%). Full model binding: 49 atoms, all sub-components preserved (ConfigTabForm, TranslationPreview, TargetTabForm, RunTabContent, ScheduleConfig, BulkReplaceModal). Save, run, retry, history — all intact. 3→2 oversized pages remaining (dashboards 809, migration 643).
This commit is contained in:
@@ -1,12 +1,7 @@
|
||||
<!-- #region TranslationJobConfig [C:4] [TYPE Page] [SEMANTICS sveltekit, translate, job, config, llm, datasource] -->
|
||||
<!-- @BRIEF Translation job configuration page — 5-step wizard with logical separation:
|
||||
Step 1 (Config tab): datasource selection (auto-detects DB dialect), column mapping,
|
||||
LLM provider, target languages (multi-select from LANGUAGES).
|
||||
Step 2 (Preview tab): translation quality gate — shows sample translations before target config.
|
||||
Step 3 (Target tab): target DB/schema/table + column mapping + schema hint validation.
|
||||
Step 4 (Run tab): incremental/full translation execution with run history.
|
||||
Step 5 (Schedule tab): cron scheduling for saved jobs only.
|
||||
-->
|
||||
<!-- @BRIEF Translation job configuration wizard — renders TranslationJobModel state. 5 tabs: Config, Preview, Target, Run, Schedule. -->
|
||||
<!-- @LAYER Page -->
|
||||
<!-- @RELATION BINDS_TO -> [TranslationJobModel] -->
|
||||
|
||||
<!-- ===== UX CONTRACTS ===== -->
|
||||
<!-- @UX_STATE idle — page not yet initialised. Skeleton loader visible. -->
|
||||
@@ -17,602 +12,63 @@
|
||||
<!-- @UX_STATE datasource_unavailable — selected datasource not found in Superset. Warning banner, datasource re-selection prompt. -->
|
||||
<!-- @UX_STATE error — unrecoverable error (job not found, network down). Error page with retry button. -->
|
||||
|
||||
<!-- @UX_STEP Config (tab) — Step 1: datasource selection with auto-detected DB dialect + column mapping + LLM + target languages (MultiSelect from LANGUAGES). Save required before advancing. -->
|
||||
<!-- @UX_STEP Preview (tab) — Step 2: translation quality gate. Available after save when configValid=true. Shows sample translations before target config. -->
|
||||
<!-- @UX_STEP Target (tab) — Step 3: target DB/schema/table config. Only for saved jobs (after Preview). Schema validation via TargetSchemaHint. DB dialect auto-detected from Superset. -->
|
||||
<!-- @UX_STEP Run (tab) — Step 4: translation execution. Run history, incremental/full modes, progress tracking via translationRunStore. -->
|
||||
<!-- @UX_STEP Schedule (tab) — Step 5: cron scheduling. Only for saved jobs. Sets schedule via API. -->
|
||||
<!-- @UX_STEP Config (tab) — Step 1: datasource selection with auto-detected DB dialect + column mapping + LLM + target languages. -->
|
||||
<!-- @UX_STEP Preview (tab) — Step 2: translation quality gate. Available after save when configValid=true. -->
|
||||
<!-- @UX_STEP Target (tab) — Step 3: target DB/schema/table config. Only for saved jobs (after Preview). -->
|
||||
<!-- @UX_STEP Run (tab) — Step 4: translation execution. Run history, incremental/full modes, progress tracking. -->
|
||||
<!-- @UX_STEP Schedule (tab) — Step 5: cron scheduling. Only for saved jobs. -->
|
||||
|
||||
<!-- @UX_FEEDBACK Toast "info" — environment switched (handleEnvChange resets targetDatabaseId). -->
|
||||
<!-- @UX_FEEDBACK Toast "success" — job saved/updated/run completed. -->
|
||||
<!-- @UX_FEEDBACK Toast "error" — API failure, validation errors, run failed. -->
|
||||
<!-- @UX_FEEDBACK Toast on save/run/error actions. -->
|
||||
<!-- @UX_FEEDBACK Modal — BulkReplaceModal for find-and-replace corrections. -->
|
||||
<!-- @UX_FEEDBACK Inline progress — TranslationRunProgress bar during execution. -->
|
||||
<!-- @UX_FEEDBACK Schema validation — TargetSchemaHint inline result (green check / red missing columns). -->
|
||||
|
||||
<!-- @UX_RECOVERY Config tab save error — user can retry save; validation errors shown inline. -->
|
||||
<!-- @UX_RECOVERY Preview unavailable — configValid incomplete; user returned to Config tab with missing fields highlighted. -->
|
||||
<!-- @UX_RECOVERY Datasource unavailable — warning banner + re-selection prompt. -->
|
||||
<!-- @UX_RECOVERY Run failure — error toast + run history shows FAILED status; user can retry. -->
|
||||
<!-- @UX_RECOVERY Schedule 404 — silently handled (no schedule = not configured); user can create one. -->
|
||||
|
||||
<!-- @UX_REACTIVITY Props -> $page.params.id (jobId), $page.params.id === 'new' (isNewJob). -->
|
||||
<!-- @UX_REACTIVITY LocalState -> $state for all form fields, $state(uxState), $state(activeTab), $state(isSaving), $state(validationErrors). -->
|
||||
<!-- @UX_REACTIVITY Derived -> $derived(configValid, pageTitle, tabs, columnList, logicalColumns). -->
|
||||
<!-- @UX_REACTIVITY Effect -> $effect watches jobId + environments to loadJob; $effect watches environmentId for datasource reload (on new jobs). -->
|
||||
<!-- @UX_REACTIVITY Store -> translationRunStore (fromStore wrapper), addToast from $lib/toasts. -->
|
||||
|
||||
<!-- @UX_DEPENDENCY Datasource selection -> auto-fills databaseDialect, sourceTable, sourceSchema. -->
|
||||
<!-- @UX_DEPENDENCY Environment selection -> reloads databases, resets targetDatabaseId. -->
|
||||
<!-- @UX_DEPENDENCY Config valid -> gates Preview tab availability and Run button. -->
|
||||
<!-- @UX_DEPENDENCY Job saved (existingJob != null) -> unlocks Target and Schedule tabs. -->
|
||||
|
||||
<!-- @UX_TEST: idle -> {wait: environments loaded, expected: configured, tabs rendered} -->
|
||||
<!-- @UX_TEST: configured -> {fill: name, select: datasource, click: save, expected: saving -> toast success} -->
|
||||
<!-- @UX_TEST: configured -> {click: Preview tab, expected: if configValid -> preview renders; else -> redirect to Config} -->
|
||||
<!-- @UX_TEST: configured -> {click: Run, expected: if configValid -> run starts, progress bar appears} -->
|
||||
<!-- @UX_TEST: error -> {simulate: network failure, expected: error toast, retry available} -->
|
||||
|
||||
<!-- @RATIONALE Target Config extracted into its own tab (between Preview and Run) so users can review translation quality before configuring the target table for inserts. -->
|
||||
<!-- @RATIONALE DB dialect auto-detected from Superset datasource — user never manually selects it; eliminates dialect mismatch errors. -->
|
||||
<!-- @RATIONALE Target languages are human languages from LANGUAGE_LABELS, not DB types — sent to LLM as translation direction. -->
|
||||
<!-- @REJECTED Keeping target table in Config tab would force users to fill insert details before seeing preview — contradicts the "preview before target" UX requirement. -->
|
||||
<!-- @REJECTED Manual DB dialect selection in UI rejected — auto-detection from Superset is more reliable and eliminates configuration errors. -->
|
||||
<!-- @REJECTED Single language field (target_language) rejected in favour of target_languages list — supports multi-language translation per row. -->
|
||||
<!-- @REJECTED Extracting all sub-tabs into separate page components was rejected due to deep state coupling between datasource selection, column loading, and form validation — would require a global store or excessive prop drilling. -->
|
||||
<!-- @DEPRECATED N/A — active page. -->
|
||||
<!-- @REPLACED_BY N/A — no replacement. -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
import { page } from '$app/state';
|
||||
import { t, _ } from '$lib/i18n/index.svelte.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import {
|
||||
fetchJobs,
|
||||
createJob,
|
||||
updateJob,
|
||||
fetchDatasources,
|
||||
} from '$lib/api/translate.js';
|
||||
import { TranslationJobModel } from '$lib/models/TranslationJobModel.svelte';
|
||||
import MultiSelect from '$lib/components/ui/MultiSelect.svelte';
|
||||
import TranslationPreview from '$lib/components/translate/TranslationPreview.svelte';
|
||||
import TranslationRunProgress from '$lib/components/translate/TranslationRunProgress.svelte';
|
||||
import TranslationRunResult from '$lib/components/translate/TranslationRunResult.svelte';
|
||||
import ScheduleConfig from '$lib/components/translate/ScheduleConfig.svelte';
|
||||
import { triggerRun, fetchRunHistory, cancelRun } from '$lib/api/translate.js';
|
||||
import BulkReplaceModal from '$lib/components/translate/BulkReplaceModal.svelte';
|
||||
import ConfigTabForm from '$lib/components/translate/ConfigTabForm.svelte';
|
||||
import TargetTabForm from '$lib/components/translate/TargetTabForm.svelte';
|
||||
import RunTabContent from '$lib/components/translate/RunTabContent.svelte';
|
||||
import {
|
||||
translationRunStore,
|
||||
startTranslationRun,
|
||||
reconnectToRun,
|
||||
resetTranslationRun,
|
||||
clearOnCompleteCallback,
|
||||
getStoredActiveRun,
|
||||
} from '$lib/stores/translationRun.svelte.js';
|
||||
|
||||
const m = new TranslationJobModel();
|
||||
|
||||
|
||||
/** @type {string} idle | loading | configured | saving | validation_error | datasource_unavailable */
|
||||
let uxState = $state('idle');
|
||||
let isNewJob = $derived(page.params.id === 'new');
|
||||
let jobId = $derived(page.params.id);
|
||||
let existingJob = $state(null);
|
||||
let error = $state(null);
|
||||
|
||||
// Form state
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
|
||||
let sourceDatasourceId = $state('');
|
||||
let sourceTable = $state('');
|
||||
let targetSchema = $state('');
|
||||
let targetTable = $state('');
|
||||
let sourceKeyCols = $state([]);
|
||||
let targetKeyCols = $state([]);
|
||||
let translationColumn = $state('');
|
||||
let targetColumn = $state('');
|
||||
let targetLanguageColumn = $state('');
|
||||
let targetSourceColumn = $state('');
|
||||
let targetSourceLanguageColumn = $state('');
|
||||
let contextColumns = $state([]);
|
||||
let targetLanguages = $state(['en']);
|
||||
let includeSourceReference = $state(true);
|
||||
let providerId = $state('');
|
||||
let batchSize = $state(50);
|
||||
let disableReasoning = $state(false);
|
||||
let upsertStrategy = $state('MERGE');
|
||||
let dictionaryIds = $state([]);
|
||||
let status = $state('DRAFT');
|
||||
|
||||
// Datasource & env state
|
||||
let environmentId = $state('');
|
||||
let environments = $state([]);
|
||||
let datasourceId = $state('');
|
||||
let datasourceSearch = $state('');
|
||||
let availableColumns = $state([]);
|
||||
let virtualColumns = $state([]);
|
||||
let databaseDialect = $state('');
|
||||
let llmProviders = $state([]);
|
||||
let availableDictionaries = $state([]);
|
||||
let targetDatabaseId = $state('');
|
||||
let databases = $state([]);
|
||||
let databasesLoading = $state(false);
|
||||
|
||||
// Tab state
|
||||
/** @type {'config'|'preview'|'target'|'run'|'schedule'} */
|
||||
let activeTab = $state('config');
|
||||
let tabs = $derived.by(() => {
|
||||
const base = [
|
||||
{ id: 'config', label: $t.translate?.config?.basic_info || 'Configuration' },
|
||||
{ id: 'preview', label: $t.translate?.preview?.title || 'Preview' },
|
||||
{ id: 'run', label: $t.translate?.config?.run_translation || 'Run' },
|
||||
];
|
||||
if (!isNewJob && existingJob) {
|
||||
// @RATIONALE Target Config tab inserted between Preview and Run so users can
|
||||
// review/preview translations before configuring the target table for inserts.
|
||||
base.splice(2, 0, { id: 'target', label: $t.translate?.config?.target_table_tab || 'Target Config' });
|
||||
base.push({ id: 'schedule', label: $t.translate?.schedule?.tab_label || 'Schedule' });
|
||||
}
|
||||
return base;
|
||||
$effect(() => {
|
||||
m.jobId = page.params.id;
|
||||
m.isNewJob = page.params.id === 'new';
|
||||
});
|
||||
|
||||
// Derived
|
||||
let columnList = $derived(availableColumns);
|
||||
let logicalColumns = $derived(availableColumns.filter(c => c.is_physical !== false));
|
||||
let virtualColumnNames = $derived(virtualColumns);
|
||||
|
||||
let isSaving = $state(false);
|
||||
let validationErrors = $state({});
|
||||
let warnings = $state([]);
|
||||
|
||||
// Derived: is the job config complete enough for preview/run?
|
||||
// @RATIONALE Removed !isNewJob from configValid — preview availability should depend
|
||||
// on config completeness only, not on whether the job was saved. The preview tab
|
||||
// content already gates rendering on existingJob via the template.
|
||||
// @REJECTED Keeping !isNewJob in configValid would block preview when user wants to
|
||||
// see results before configuring target table details.
|
||||
let configValid = $derived(
|
||||
!!translationColumn
|
||||
&& !!datasourceId
|
||||
&& targetLanguages.length > 0
|
||||
&& !!providerId
|
||||
);
|
||||
let pageTitle = $derived.by(() => {
|
||||
if (isNewJob) return $t.translate?.config?.new_title || 'New Translation Job';
|
||||
const jobName = existingJob?.name || name;
|
||||
const base = $t.translate?.config?.edit_title || 'Edit Translation Job';
|
||||
return jobName ? `${jobName} | ${base}` : base;
|
||||
});
|
||||
|
||||
// Run state — using global store to survive page navigation
|
||||
let currentRunId = $derived(translationRunStore.value?.runId || null);
|
||||
let completedRuns = $state([]);
|
||||
let expandedRunIds = $state([]);
|
||||
let isRunning = $state(false);
|
||||
let isFullRun = $state(false);
|
||||
let runError = $state('');
|
||||
let showPageBulkReplace = $state(false);
|
||||
/** Local flag to hide progress bar when run completes — avoids touching the store
|
||||
* and triggering cascading reactive updates through fromStore. */
|
||||
let runComplete = $state(false);
|
||||
|
||||
async function handleTriggerRun(full = false) {
|
||||
isRunning = true;
|
||||
runComplete = false;
|
||||
isFullRun = full;
|
||||
runError = '';
|
||||
try {
|
||||
const run = await triggerRun(jobId, full);
|
||||
startTranslationRun(run.id, { jobId, isFullRun: full, onComplete: handleRunComplete });
|
||||
addToast(full ? 'Полный перевод запущен (все строки)' : _('translate.config.run_started'), 'success');
|
||||
} catch (err) {
|
||||
runError = err?.message || _('translate.config.run_failed');
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRunComplete(statusData) {
|
||||
if (runComplete) return; // Idempotent: run already marked complete
|
||||
const finishedRunId = currentRunId;
|
||||
isRunning = false;
|
||||
runComplete = true;
|
||||
expandedRunIds = [];
|
||||
if (statusData?.status !== 'CANCELLED') {
|
||||
loadRunHistory();
|
||||
}
|
||||
addToast(`${_('translate.run.run_id')} ${finishedRunId}`, 'info');
|
||||
}
|
||||
|
||||
async function loadRunHistory() {
|
||||
try {
|
||||
const data = await fetchRunHistory(jobId, { page_size: 5 });
|
||||
completedRuns = (data?.items || []).filter(
|
||||
(run) => run && typeof run.id === 'string' && run.id.trim().length > 0
|
||||
);
|
||||
expandedRunIds = expandedRunIds.filter((runId) => completedRuns.some((run) => run.id === runId));
|
||||
} catch (_err) {
|
||||
completedRuns = [];
|
||||
expandedRunIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRunDetails(runId) {
|
||||
if (!runId) return;
|
||||
if (expandedRunIds.includes(runId)) {
|
||||
expandedRunIds = expandedRunIds.filter((id) => id !== runId);
|
||||
} else {
|
||||
expandedRunIds = [...expandedRunIds, runId];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetryRun() {
|
||||
if (currentRunId) {
|
||||
try {
|
||||
await cancelRun(currentRunId);
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
resetTranslationRun();
|
||||
await handleTriggerRun();
|
||||
}
|
||||
|
||||
async function handleRetryInsert() {
|
||||
if (currentRunId) {
|
||||
try {
|
||||
const result = await api.postApi(`/translate/runs/${currentRunId}/retry-insert`, {});
|
||||
addToast(_('translate.config.insert_retry_started'), 'success');
|
||||
} catch (err) {
|
||||
addToast(err?.message || _('translate.config.insert_retry_failed'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Load environments first
|
||||
try {
|
||||
const envs = await api.getEnvironments?.() || [];
|
||||
environments = Array.isArray(envs) ? envs : [];
|
||||
if (environments.length > 0 && !environmentId) {
|
||||
environmentId = environments[0]?.id || '';
|
||||
m.environments = Array.isArray(envs) ? envs : [];
|
||||
if (m.environments.length > 0 && !m.environmentId) {
|
||||
m.environmentId = (m.environments[0]?.id as string) || '';
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
await loadInitialData();
|
||||
|
||||
// Handle new-job initialisation (runs once on mount)
|
||||
if (isNewJob) {
|
||||
if (environmentId) {
|
||||
await loadDatabases();
|
||||
}
|
||||
uxState = 'configured';
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
await m.loadInitialData();
|
||||
if (m.isNewJob && m.environmentId) { await m.loadDatabases(); }
|
||||
});
|
||||
|
||||
// Watch for jobId changes — handles:
|
||||
// 1. Direct navigation to existing job page (environments loaded in onMount)
|
||||
// 2. Navigation after createJob (goto → param change, component reused)
|
||||
// 3. Page reload — reconnect to any active run via sessionStorage
|
||||
// Guards: only runs when environments are ready and it's an existing job
|
||||
$effect(() => {
|
||||
if (jobId && !isNewJob && environments.length > 0) {
|
||||
loadJob();
|
||||
}
|
||||
const env = m.environmentId;
|
||||
if (env && m.isNewJob) { m.loadDatabases(); if (m.datasourceId) m.loadDatasourceColumns(); }
|
||||
});
|
||||
|
||||
/** Pick up active run after job loads — called from loadJob() after run history loads.
|
||||
* NOT a $effect — checking sessionStorage + completedRuns[0] is a one-time operation
|
||||
* that must NOT reactively track translationRunStore.value (would create infinite loop). */
|
||||
function pickUpActiveRun(): void {
|
||||
if (!existingJob || !existingJob.id) return;
|
||||
// Don't reconnect if store is already connected to a run for this job
|
||||
if (translationRunStore.value?.runId && translationRunStore.value?.jobId === existingJob.id) return;
|
||||
|
||||
// 1. Fast path: sessionStorage (our own run, across reload/tab-close)
|
||||
const stored = getStoredActiveRun();
|
||||
if (stored && stored.jobId === existingJob.id) {
|
||||
console.debug('[translate] picking up active run from sessionStorage', { runId: stored.runId });
|
||||
isRunning = true;
|
||||
isFullRun = stored.isFullRun || false;
|
||||
runComplete = false;
|
||||
reconnectToRun(stored.runId, {
|
||||
jobId: existingJob.id,
|
||||
isFullRun: stored.isFullRun || false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fallback: API-based detection (run started by other tab / before fix)
|
||||
if (!completedRuns.length) return;
|
||||
const latest = completedRuns[0] as Record<string, unknown> | undefined;
|
||||
if (!latest || typeof latest.id !== 'string') return;
|
||||
const runStatus = latest.status as string;
|
||||
if (runStatus !== 'PENDING' && runStatus !== 'RUNNING') return;
|
||||
|
||||
console.debug('[translate] picking up active run from API', { runId: latest.id, status: runStatus });
|
||||
isRunning = true;
|
||||
isFullRun = latest.isFullRun === true;
|
||||
runComplete = false;
|
||||
reconnectToRun(latest.id, {
|
||||
jobId: existingJob.id,
|
||||
isFullRun: latest.isFullRun === true,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up store callback when page unmounts — the store keeps
|
||||
// polling so the global indicator in the layout still works.
|
||||
onDestroy(() => {
|
||||
clearOnCompleteCallback();
|
||||
});
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
// Load LLM providers
|
||||
try {
|
||||
const consolidated = await api.getConsolidatedSettings?.() || {};
|
||||
llmProviders = consolidated.llm_providers || [];
|
||||
// Also try settings endpoint
|
||||
if (llmProviders.length === 0 && api.getLlmStatus) {
|
||||
const llmStatus = await api.getLlmStatus().catch(() => ({}));
|
||||
if (llmStatus?.providers) {
|
||||
llmProviders = llmStatus.providers;
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
llmProviders = [];
|
||||
}
|
||||
|
||||
// Load dictionaries (from available translate dictionaries)
|
||||
try {
|
||||
const result = await api.fetchApi('/translate/dictionaries').catch(() => ({}));
|
||||
availableDictionaries = Array.isArray(result) ? result : (result?.items || []);
|
||||
} catch (_e) {
|
||||
availableDictionaries = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[translate/config] Failed to load some initial data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function loadJob() {
|
||||
uxState = 'loading';
|
||||
try {
|
||||
const jobs = await fetchJobs({ page_size: 100 });
|
||||
const jobsArr = Array.isArray(jobs) ? jobs : (jobs?.results || []);
|
||||
const job = jobsArr.find(j => j.id === jobId);
|
||||
if (!job) {
|
||||
// Try direct fetch
|
||||
try {
|
||||
const directJob = await api.fetchApi(`/translate/jobs/${jobId}`);
|
||||
existingJob = directJob;
|
||||
} catch (_e) {
|
||||
error = _('translate.config.job_not_found').replace('{id}', jobId);
|
||||
uxState = 'error';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
existingJob = job;
|
||||
}
|
||||
|
||||
// Populate form
|
||||
const j = existingJob;
|
||||
name = j.name || '';
|
||||
description = j.description || '';
|
||||
sourceDatasourceId = j.source_datasource_id || '';
|
||||
sourceTable = j.source_table || '';
|
||||
targetSchema = j.target_schema || '';
|
||||
targetTable = j.target_table || '';
|
||||
sourceKeyCols = j.source_key_cols || [];
|
||||
targetKeyCols = j.target_key_cols || [];
|
||||
translationColumn = j.translation_column || '';
|
||||
targetColumn = j.target_column || '';
|
||||
targetLanguageColumn = j.target_language_column || '';
|
||||
targetSourceColumn = j.target_source_column || '';
|
||||
targetSourceLanguageColumn = j.target_source_language_column || '';
|
||||
contextColumns = j.context_columns || [];
|
||||
targetLanguages = j.target_languages?.length > 0 ? j.target_languages : (j.target_language ? [j.target_language] : ['en']);
|
||||
providerId = j.provider_id || '';
|
||||
batchSize = j.batch_size || 50;
|
||||
includeSourceReference = j.include_source_reference ?? true;
|
||||
disableReasoning = j.disable_reasoning || false;
|
||||
upsertStrategy = j.upsert_strategy || 'MERGE';
|
||||
dictionaryIds = j.dictionary_ids || [];
|
||||
status = j.status || 'DRAFT';
|
||||
|
||||
// Restore target database
|
||||
if (j.target_database_id) {
|
||||
targetDatabaseId = j.target_database_id;
|
||||
}
|
||||
|
||||
// Restore environment from stored job FIRST (before loading databases)
|
||||
if (j.environment_id && environments.some(e => e.id === j.environment_id)) {
|
||||
environmentId = j.environment_id;
|
||||
} else {
|
||||
environmentId = environments[0]?.id || '';
|
||||
}
|
||||
|
||||
// Load databases for the (now correct) environment
|
||||
if (environmentId) {
|
||||
await loadDatabases();
|
||||
}
|
||||
|
||||
// If there's a datasource, load its columns and look up the name
|
||||
if (j.source_datasource_id && environments.length > 0) {
|
||||
datasourceId = j.source_datasource_id;
|
||||
if (j.source_table) {
|
||||
const dialect = j.database_dialect || '';
|
||||
datasourceSearch = dialect
|
||||
? `${j.source_table} (${dialect})`
|
||||
: j.source_table;
|
||||
} else {
|
||||
// Fallback — try to look up the actual datasource name from the API
|
||||
datasourceSearch = `Dataset #${j.source_datasource_id}`;
|
||||
try {
|
||||
const dsList = await fetchDatasources(environmentId, '');
|
||||
const found = dsList.find(ds => String(ds.id) === String(j.source_datasource_id));
|
||||
if (found) {
|
||||
datasourceSearch = `${found.table_name} (${found.database_name} · ${found.database_dialect})`;
|
||||
databaseDialect = found.database_dialect || '';
|
||||
}
|
||||
} catch (_e) {
|
||||
// Keep fallback "Dataset #<id>" if API fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load run history
|
||||
await loadRunHistory();
|
||||
// Check for active run (sessionStorage or API) — runs once, no reactive cycle
|
||||
pickUpActiveRun();
|
||||
uxState = 'configured';
|
||||
} catch (err) {
|
||||
error = err?.message || 'Failed to load job';
|
||||
uxState = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDatabases() {
|
||||
if (!environmentId) {
|
||||
databases = [];
|
||||
return;
|
||||
}
|
||||
databasesLoading = true;
|
||||
try {
|
||||
databases = await api.getEnvironmentDatabases(environmentId) || [];
|
||||
} catch (_e) {
|
||||
databases = [];
|
||||
} finally {
|
||||
databasesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Event} event */
|
||||
async function handleEnvChange(event) {
|
||||
const newEnv = event.target.value;
|
||||
environmentId = newEnv;
|
||||
databaseDialect = '';
|
||||
targetDatabaseId = '';
|
||||
await loadDatabases();
|
||||
}
|
||||
|
||||
/** @returns {string[]} */
|
||||
function validate() {
|
||||
const errs = {};
|
||||
const warns = [];
|
||||
|
||||
if (!name.trim()) {
|
||||
errs.name = _('translate.config.name_required');
|
||||
}
|
||||
|
||||
if (targetLanguages.length === 0) {
|
||||
errs.targetLanguages = 'At least one target language is required';
|
||||
}
|
||||
|
||||
if (!translationColumn) {
|
||||
errs.translationColumn = _('translate.config.translation_column_required');
|
||||
}
|
||||
|
||||
if (sourceKeyCols.length > 0 && sourceKeyCols.some((_, i) => !targetKeyCols[i])) {
|
||||
errs.targetKeyCols = _('translate.config.key_mapping_required');
|
||||
}
|
||||
|
||||
// Warn about virtual key columns
|
||||
for (const col of sourceKeyCols) {
|
||||
if (virtualColumnNames.includes(col)) {
|
||||
warns.push(_('translate.config.virtual_column_warning').replace('{col}', col));
|
||||
}
|
||||
}
|
||||
|
||||
// Check duplicate column mapping
|
||||
if (contextColumns.includes(translationColumn)) {
|
||||
warns.push(_('translate.config.redundant_column_warning'));
|
||||
}
|
||||
|
||||
validationErrors = errs;
|
||||
warnings = warns;
|
||||
return Object.keys(errs);
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function handleSave() {
|
||||
const errs = validate();
|
||||
if (errs.length > 0) {
|
||||
uxState = 'validation_error';
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
uxState = 'saving';
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
source_dialect: existingJob?.source_dialect || 'SQL',
|
||||
target_dialect: existingJob?.target_dialect || 'en',
|
||||
source_datasource_id: datasourceId || null,
|
||||
source_table: sourceTable || null,
|
||||
target_schema: targetSchema || null,
|
||||
target_table: targetTable || null,
|
||||
source_key_cols: sourceKeyCols,
|
||||
target_key_cols: targetKeyCols,
|
||||
translation_column: translationColumn || null,
|
||||
target_column: targetColumn || null,
|
||||
target_language_column: targetLanguageColumn || null,
|
||||
target_source_column: targetSourceColumn || null,
|
||||
target_source_language_column: targetSourceLanguageColumn || null,
|
||||
context_columns: contextColumns,
|
||||
target_languages: targetLanguages?.length > 0 ? targetLanguages : null,
|
||||
provider_id: providerId || null,
|
||||
batch_size: parseInt(batchSize) || 50,
|
||||
include_source_reference: includeSourceReference,
|
||||
disable_reasoning: disableReasoning,
|
||||
upsert_strategy: upsertStrategy,
|
||||
dictionary_ids: dictionaryIds,
|
||||
database_dialect: databaseDialect || existingJob?.source_dialect || '',
|
||||
environment_id: environmentId || null,
|
||||
target_database_id: targetDatabaseId || null,
|
||||
status: status,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isNewJob) {
|
||||
const created = await createJob(payload);
|
||||
existingJob = created;
|
||||
addToast(_('translate.config.job_created'), 'success');
|
||||
goto(ROUTES.translate.detail(created.id));
|
||||
} else {
|
||||
await updateJob(jobId, payload);
|
||||
addToast(_('translate.config.job_updated'), 'success');
|
||||
uxState = 'configured';
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(err?.message || _('translate.config.save_failed'), 'error');
|
||||
uxState = 'validation_error';
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getJobStatusLabel(value) {
|
||||
const labels = {
|
||||
DRAFT: $t.translate?.config?.status_draft || 'Draft',
|
||||
READY: $t.translate?.config?.status_ready || 'Ready',
|
||||
ACTIVE: $t.translate?.config?.status_active || 'Active',
|
||||
RUNNING: $t.translate?.jobs?.status_running || 'Running',
|
||||
COMPLETED: $t.translate?.jobs?.status_completed || 'Completed',
|
||||
FAILED: $t.translate?.jobs?.status_failed || 'Failed',
|
||||
CANCELLED: $t.translate?.history?.status_cancelled || 'Cancelled',
|
||||
};
|
||||
return labels[value] || value || ($t.translate?.config?.status_draft || 'Draft');
|
||||
}
|
||||
function goBack() { goto(ROUTES.translate.list()); }
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle}</title>
|
||||
<title>{m.pageTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-full xl:max-w-7xl">
|
||||
@@ -620,84 +76,66 @@
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-text">
|
||||
{isNewJob ? $t.translate?.config?.new_title : $t.translate?.config?.edit_title}
|
||||
{m.isNewJob ? $t.translate?.config?.new_title : $t.translate?.config?.edit_title}
|
||||
</h1>
|
||||
{#if !isNewJob && existingJob}
|
||||
<p class="text-sm text-text-muted mt-1">{$t.translate?.config?.id_label.replace('{id}', existingJob.id)}</p>
|
||||
{#if !m.isNewJob && m.existingJob}
|
||||
<p class="text-sm text-text-muted mt-1">{$t.translate?.config?.id_label.replace('{id}', m.existingJob.id)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={() => goto(ROUTES.translate.list())}
|
||||
class="px-4 py-2 border border-border-strong text-text rounded-lg hover:bg-surface-muted transition-colors"
|
||||
>
|
||||
<button onclick={() => goto(ROUTES.translate.list())} class="px-4 py-2 border border-border-strong text-text rounded-lg hover:bg-surface-muted transition-colors">
|
||||
{$t.translate?.config?.cancel}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? $t.translate?.config?.saving : $t.translate?.config?.save}
|
||||
<button onclick={() => m.saveJob()} disabled={m.isSaving} class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
{m.isSaving ? $t.translate?.config?.saving : $t.translate?.config?.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
{#if warnings.length > 0}
|
||||
{#if m.warnings.length > 0}
|
||||
<div class="bg-warning-light border border-warning rounded-lg p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-warning mb-2">{$t.translate?.config?.warnings_title}</h4>
|
||||
<ul class="list-disc list-inside text-sm text-warning space-y-1">
|
||||
{#each warnings as w}
|
||||
{#each m.warnings as w}
|
||||
<li>{w}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if uxState === 'loading'}
|
||||
<div class="space-y-6">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-16 bg-surface-muted rounded-lg animate-pulse" />
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Loading -->
|
||||
{#if m.uxState === 'loading'}
|
||||
<div class="space-y-6">{#each Array(6) as _}<div class="h-16 bg-surface-muted rounded-lg animate-pulse" />{/each}</div>
|
||||
|
||||
<!-- Error State -->
|
||||
{:else if uxState === 'error'}
|
||||
<!-- Error -->
|
||||
{:else if m.uxState === 'error'}
|
||||
<div class="bg-destructive-light border border-destructive-light rounded-lg p-6 text-center">
|
||||
<p class="text-destructive mb-3">{error || $t.translate?.common?.unknown_error}</p>
|
||||
<button
|
||||
onclick={() => goto(ROUTES.translate.list())}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{$t.translate?.config?.back_to_jobs}
|
||||
</button>
|
||||
<p class="text-destructive mb-3">{m.error || $t.translate?.common?.unknown_error}</p>
|
||||
<button onclick={() => goto(ROUTES.translate.list())} class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors">{$t.translate?.config?.back_to_jobs}</button>
|
||||
</div>
|
||||
|
||||
<!-- Datasource Unavailable -->
|
||||
{:else if uxState === 'datasource_unavailable'}
|
||||
{:else if m.uxState === 'datasource_unavailable'}
|
||||
<div class="bg-warning-light border border-orange-200 rounded-lg p-4 mb-6">
|
||||
<p class="text-warning text-sm">
|
||||
{$t.translate?.config?.datasource_unavailable}
|
||||
</p>
|
||||
<p class="text-warning text-sm">{$t.translate?.config?.datasource_unavailable}</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
{:else if uxState === 'configured' || uxState === 'saving' || uxState === 'validation_error' || uxState === 'idle'}
|
||||
{:else if m.uxState === 'configured' || m.uxState === 'saving' || m.uxState === 'validation_error' || m.uxState === 'idle'}
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-border mb-6">
|
||||
<nav class="flex space-x-1" aria-label="Tabs">
|
||||
{#each tabs as tab}
|
||||
{#each m.tabs as tab}
|
||||
<button
|
||||
onclick={() => { activeTab = tab.id; }}
|
||||
disabled={(tab.id === 'preview' && (!configValid || isNewJob)) || (tab.id === 'target' && isNewJob)}
|
||||
class="px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors
|
||||
{activeTab === tab.id
|
||||
? 'bg-surface-card text-primary border border-b-white border-border -mb-px'
|
||||
: 'text-text-muted hover:text-text hover:bg-surface-muted'}
|
||||
{(tab.id === 'preview' && (!configValid || isNewJob)) || (tab.id === 'target' && isNewJob) ? 'opacity-40 cursor-not-allowed' : ''}"
|
||||
title={tab.id === 'preview' && (!configValid || isNewJob) ? ($t.translate?.preview?.config_incomplete || 'Configuration incomplete') : tab.id === 'target' && isNewJob ? ($t.translate?.config?.save_job_first || 'Save the job first') : ''}
|
||||
onclick={() => { m.activeTab = tab.id; }}
|
||||
disabled={(tab.id === 'preview' && (!m.configValid || m.isNewJob)) || (tab.id === 'target' && m.isNewJob)}
|
||||
class="px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors
|
||||
{m.activeTab === tab.id
|
||||
? 'bg-surface-card text-primary border border-b-white border-border -mb-px'
|
||||
: 'text-text-muted hover:text-text hover:bg-surface-muted'}
|
||||
{(tab.id === 'preview' && (!m.configValid || m.isNewJob)) || (tab.id === 'target' && m.isNewJob) ? 'opacity-40 cursor-not-allowed' : ''}"
|
||||
title={tab.id === 'preview' && (!m.configValid || m.isNewJob) ? ($t.translate?.preview?.config_incomplete || 'Configuration incomplete') : tab.id === 'target' && m.isNewJob ? ($t.translate?.config?.save_job_first || 'Save the job first') : ''}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
@@ -705,131 +143,126 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Config tab: always mounted to preserve state across tab switches -->
|
||||
<div class={activeTab === 'config' ? '' : 'hidden'}>
|
||||
<!-- Config tab -->
|
||||
<div class={m.activeTab === 'config' ? '' : 'hidden'}>
|
||||
<ConfigTabForm
|
||||
bind:name
|
||||
bind:description
|
||||
bind:environmentId
|
||||
{environments}
|
||||
bind:datasourceId
|
||||
bind:datasourceSearch
|
||||
bind:databaseDialect
|
||||
bind:availableColumns
|
||||
bind:virtualColumns
|
||||
bind:sourceKeyCols
|
||||
bind:targetKeyCols
|
||||
bind:translationColumn
|
||||
bind:contextColumns
|
||||
bind:targetLanguages
|
||||
bind:targetLanguageColumn
|
||||
bind:targetSourceColumn
|
||||
bind:targetSourceLanguageColumn
|
||||
bind:providerId
|
||||
{llmProviders}
|
||||
bind:dictionaryIds
|
||||
{availableDictionaries}
|
||||
bind:disableReasoning
|
||||
{validationErrors}
|
||||
onEnvChange={handleEnvChange}
|
||||
bind:name={m.name}
|
||||
bind:description={m.description}
|
||||
bind:environmentId={m.environmentId}
|
||||
environments={m.environments}
|
||||
bind:datasourceId={m.datasourceId}
|
||||
bind:datasourceSearch={m.datasourceSearch}
|
||||
bind:databaseDialect={m.databaseDialect}
|
||||
bind:availableColumns={m.availableColumns}
|
||||
bind:virtualColumns={m.virtualColumns}
|
||||
bind:sourceKeyCols={m.sourceKeyCols}
|
||||
bind:targetKeyCols={m.targetKeyCols}
|
||||
bind:translationColumn={m.translationColumn}
|
||||
bind:contextColumns={m.contextColumns}
|
||||
bind:targetLanguages={m.targetLanguages}
|
||||
bind:targetLanguageColumn={m.targetLanguageColumn}
|
||||
bind:targetSourceColumn={m.targetSourceColumn}
|
||||
bind:targetSourceLanguageColumn={m.targetSourceLanguageColumn}
|
||||
bind:providerId={m.providerId}
|
||||
llmProviders={m.llmProviders}
|
||||
bind:dictionaryIds={m.dictionaryIds}
|
||||
availableDictionaries={m.availableDictionaries}
|
||||
bind:disableReasoning={m.disableReasoning}
|
||||
validationErrors={m.validationErrors}
|
||||
onEnvChange={(e) => m.handleEnvChange(e.detail)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Schedule tab: mounted when not on new-job page, hidden when not active -->
|
||||
{#if !isNewJob}
|
||||
<div class={activeTab === 'schedule' ? '' : 'hidden'}>
|
||||
<section class="bg-surface-card border border-border rounded-lg">
|
||||
<ScheduleConfig {jobId} />
|
||||
</section>
|
||||
</div>
|
||||
<!-- Schedule tab -->
|
||||
{#if !m.isNewJob}
|
||||
<div class={m.activeTab === 'schedule' ? '' : 'hidden'}>
|
||||
<section class="bg-surface-card border border-border rounded-lg">
|
||||
<ScheduleConfig jobId={m.jobId} />
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Preview tab: always mounted to preserve state across tab switches -->
|
||||
<div class={activeTab === 'preview' ? '' : 'hidden'}>
|
||||
{#if !isNewJob && existingJob}
|
||||
{#if !configValid}
|
||||
<div class="bg-warning-light border border-warning rounded-lg p-6 text-center">
|
||||
<p class="text-sm text-warning font-medium mb-2">
|
||||
{$t.translate?.preview?.config_incomplete}
|
||||
</p>
|
||||
<p class="text-xs text-warning">
|
||||
{$t.translate?.preview?.config_requirements}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Preview tab -->
|
||||
<div class={m.activeTab === 'preview' ? '' : 'hidden'}>
|
||||
{#if !m.isNewJob && m.existingJob}
|
||||
{#if !m.configValid}
|
||||
<div class="bg-warning-light border border-warning rounded-lg p-6 text-center">
|
||||
<p class="text-sm text-warning font-medium mb-2">{$t.translate?.preview?.config_incomplete}</p>
|
||||
<p class="text-xs text-warning">{$t.translate?.preview?.config_requirements}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TranslationPreview jobId={m.jobId} environmentId={m.environmentId} />
|
||||
{/if}
|
||||
{:else}
|
||||
<TranslationPreview {jobId} {environmentId} />
|
||||
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Target Config tab: target table configuration for inserts — after preview, before run -->
|
||||
<div class={activeTab === 'target' ? '' : 'hidden'}>
|
||||
{#if !isNewJob && existingJob}
|
||||
<TargetTabForm
|
||||
bind:targetSchema
|
||||
bind:targetTable
|
||||
bind:targetDatabaseId
|
||||
bind:targetColumn
|
||||
bind:targetLanguageColumn
|
||||
bind:targetSourceColumn
|
||||
bind:targetSourceLanguageColumn
|
||||
bind:targetKeyCols
|
||||
bind:translationColumn
|
||||
bind:batchSize
|
||||
bind:includeSourceReference
|
||||
bind:upsertStrategy
|
||||
{environmentId}
|
||||
{databases}
|
||||
{databasesLoading}
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Target Config tab -->
|
||||
<div class={m.activeTab === 'target' ? '' : 'hidden'}>
|
||||
{#if !m.isNewJob && m.existingJob}
|
||||
<TargetTabForm
|
||||
bind:targetSchema={m.targetSchema}
|
||||
bind:targetTable={m.targetTable}
|
||||
bind:targetDatabaseId={m.targetDatabaseId}
|
||||
bind:targetColumn={m.targetColumn}
|
||||
bind:targetLanguageColumn={m.targetLanguageColumn}
|
||||
bind:targetSourceColumn={m.targetSourceColumn}
|
||||
bind:targetSourceLanguageColumn={m.targetSourceLanguageColumn}
|
||||
bind:targetKeyCols={m.targetKeyCols}
|
||||
bind:translationColumn={m.translationColumn}
|
||||
bind:batchSize={m.batchSize}
|
||||
bind:includeSourceReference={m.includeSourceReference}
|
||||
bind:upsertStrategy={m.upsertStrategy}
|
||||
environmentId={m.environmentId}
|
||||
databases={m.databases}
|
||||
databasesLoading={m.databasesLoading}
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Run tab: always mounted to preserve state across tab switches -->
|
||||
<div class={activeTab === 'run' ? '' : 'hidden'}>
|
||||
{#if !isNewJob && existingJob}
|
||||
<RunTabContent
|
||||
bind:status
|
||||
{jobId}
|
||||
{isRunning}
|
||||
{isFullRun}
|
||||
{runError}
|
||||
{currentRunId}
|
||||
{runComplete}
|
||||
{completedRuns}
|
||||
{expandedRunIds}
|
||||
bind:showPageBulkReplace
|
||||
onTriggerRun={handleTriggerRun}
|
||||
onRetryRun={handleRetryRun}
|
||||
onRetryInsert={handleRetryInsert}
|
||||
onLoadRunHistory={loadRunHistory}
|
||||
onToggleRunDetails={toggleRunDetails}
|
||||
onBulkReplaceApplied={() => { showPageBulkReplace = false; loadRunHistory(); }}
|
||||
onBulkReplaceClose={() => showPageBulkReplace = false}
|
||||
{getJobStatusLabel}
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Run tab -->
|
||||
<div class={m.activeTab === 'run' ? '' : 'hidden'}>
|
||||
{#if !m.isNewJob && m.existingJob}
|
||||
<RunTabContent
|
||||
bind:status={m.status}
|
||||
jobId={m.jobId}
|
||||
isRunning={m.isRunning}
|
||||
isFullRun={m.isFullRun}
|
||||
runError={m.runError}
|
||||
currentRunId={m.currentRunId}
|
||||
runComplete={m.runComplete}
|
||||
completedRuns={m.completedRuns}
|
||||
expandedRunIds={m.expandedRunIds}
|
||||
bind:showPageBulkReplace={m.showPageBulkReplace}
|
||||
onTriggerRun={m.handleTriggerRun}
|
||||
onRetryRun={m.handleRetryRun}
|
||||
onRetryInsert={m.handleRetryInsert}
|
||||
onLoadRunHistory={m.loadRunHistory}
|
||||
onToggleRunDetails={m.toggleRunDetails}
|
||||
onBulkReplaceApplied={() => { m.showPageBulkReplace = false; m.loadRunHistory(); }}
|
||||
onBulkReplaceClose={() => m.showPageBulkReplace = false}
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<BulkReplaceModal
|
||||
show={showPageBulkReplace}
|
||||
runId={completedRuns[0]?.id || ''}
|
||||
targetLanguages={targetLanguages}
|
||||
onClose={() => showPageBulkReplace = false}
|
||||
onApplied={(count) => { showPageBulkReplace = false; loadRunHistory(); }}
|
||||
show={m.showPageBulkReplace}
|
||||
runId={m.completedRuns[0]?.id || ''}
|
||||
targetLanguages={m.targetLanguages}
|
||||
onClose={() => m.showPageBulkReplace = false}
|
||||
onApplied={() => { m.showPageBulkReplace = false; m.loadRunHistory(); }}
|
||||
/>
|
||||
<!-- #endregion TranslationJobConfig -->
|
||||
|
||||
Reference in New Issue
Block a user