refactor(translate): remove DRAFT status from UI, auto-transition via preflight
- Remove DRAFT badge, 'Mark as READY' button, DRAFT filter pill, DRAFT run gate - Auto-transition DRAFT<->READY based on runReady (all required preflight items) - Move PreflightChecklist to page top — always visible across tabs - Remove orchestration DRAFT gate — preflight handles readiness - Remove 10 dead i18n keys (status_draft, mark_ready, disabled_draft, hint_draft) - Add reverse transition READY->DRAFT when fields cleared - Remove unused runComplete prop from RunTabContent - Add fallback badge styling for unrecognized statuses
This commit is contained in:
119
frontend/src/lib/components/translate/PreflightChecklist.svelte
Normal file
119
frontend/src/lib/components/translate/PreflightChecklist.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<!-- #region PreflightChecklist [C:2] [TYPE Component] [SEMANTICS translate, run, preflight, checklist, readiness] -->
|
||||
<!-- @ingroup Translate -->
|
||||
<!-- @BRIEF Compact preflight readiness checklist — progress ring + expandable details.
|
||||
Shows required (★) and optional items, with inline missing-field chips when collapsed. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @UX_STATE Collapsed — summary bar with progress ring + missing chips -->
|
||||
<!-- @UX_STATE Expanded — grid of all readiness items with check/cross icons -->
|
||||
<!-- @UX_REACTIVITY Props -> $props() for runReadiness array -->
|
||||
<script lang="ts">
|
||||
import { getT } from '$lib/i18n/index.svelte.js';
|
||||
|
||||
let {
|
||||
runReadiness = [],
|
||||
onItemClick = (_key: string, _tab: string) => {},
|
||||
} = $props();
|
||||
|
||||
const _t = $derived(getT());
|
||||
const rr = $derived(_t.translate?.jobs?.run_readiness ?? _t.translate?.config?.run_readiness);
|
||||
|
||||
// Map item keys to i18n label keys
|
||||
const KEY_I18N: Record<string, string> = {
|
||||
'name': 'label_name', 'datasource': 'label_datasource',
|
||||
'translationColumn': 'label_translation_column', 'targetLanguages': 'label_target_languages',
|
||||
'provider': 'label_provider', 'targetSchema': 'label_target_schema',
|
||||
'targetTable': 'label_target_table', 'schemaValidated': 'label_schema_validated',
|
||||
'connectionId': 'label_connection_id',
|
||||
};
|
||||
function tLabel(key: string, fallback: string): string {
|
||||
const k = KEY_I18N[key];
|
||||
return (k && (rr as Record<string, string>)?.[k]) || fallback;
|
||||
}
|
||||
function tHint(key: string, fallback: string): string {
|
||||
const k = KEY_I18N[key]?.replace('label_', 'hint_');
|
||||
return (k && (rr as Record<string, string>)?.[k]) || fallback;
|
||||
}
|
||||
|
||||
let showChecklist = $state(false);
|
||||
let readyCount = $derived(runReadiness.filter((i: { ok: boolean }) => i.ok).length);
|
||||
let totalCount = $derived(runReadiness.length);
|
||||
let missingRequired = $derived(runReadiness.filter((i: { ok: boolean; required: boolean }) => !i.ok && i.required));
|
||||
let allReady = $derived(readyCount === totalCount && totalCount > 0);
|
||||
</script>
|
||||
|
||||
{#if runReadiness.length > 0}
|
||||
<div class="mb-4 rounded-lg border {allReady ? 'border-success/30 bg-success-light' : 'border-warning/30 bg-warning-light'}">
|
||||
<!-- Summary bar — always visible -->
|
||||
<button
|
||||
onclick={() => (showChecklist = !showChecklist)}
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-left"
|
||||
aria-expanded={showChecklist}
|
||||
>
|
||||
<!-- Progress ring -->
|
||||
<div class="relative flex-shrink-0 w-7 h-7">
|
||||
<svg class="w-7 h-7 -rotate-90" viewBox="0 0 28 28">
|
||||
<circle cx="14" cy="14" r="11" fill="none" stroke="currentColor" stroke-width="3" class="text-surface-muted opacity-40" />
|
||||
<circle
|
||||
cx="14" cy="14" r="11" fill="none" stroke="currentColor" stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="{2 * Math.PI * 11}"
|
||||
stroke-dashoffset="{2 * Math.PI * 11 * (1 - readyCount / Math.max(totalCount, 1))}"
|
||||
class={allReady ? 'text-success' : 'text-warning'}
|
||||
/>
|
||||
</svg>
|
||||
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-bold {allReady ? 'text-success' : 'text-warning'}">
|
||||
{readyCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Status text -->
|
||||
<span class="text-sm font-medium flex-1 {allReady ? 'text-success' : 'text-warning'}">
|
||||
{#if allReady}
|
||||
{rr?.ready_to_run || 'Ready to run'}
|
||||
{:else}
|
||||
{missingRequired.length} {rr?.missing_required || 'required items missing'}
|
||||
{/if}
|
||||
</span>
|
||||
<!-- Missing chips (compact, inline) -->
|
||||
{#if !allReady && missingRequired.length > 0 && !showChecklist}
|
||||
<div class="hidden sm:flex items-center gap-1 flex-wrap justify-end max-w-[50%]">
|
||||
{#each missingRequired.slice(0, 3) as item}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[11px] bg-warning/10 text-warning border border-warning/20">
|
||||
{tLabel(item.key, item.label)}
|
||||
</span>
|
||||
{/each}
|
||||
{#if missingRequired.length > 3}
|
||||
<span class="text-[11px] text-text-subtle">+{missingRequired.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Expand chevron -->
|
||||
<svg class="w-4 h-4 text-text-subtle transition-transform flex-shrink-0 {showChecklist ? 'rotate-180' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Expandable details -->
|
||||
{#if showChecklist}
|
||||
<div class="border-t border-border/50 px-4 py-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-1.5">
|
||||
{#each runReadiness as item}
|
||||
<div class="flex items-center gap-2 text-xs {!item.ok ? 'cursor-pointer hover:bg-surface-muted rounded px-1 -mx-1 py-0.5 transition-colors' : ''}" role={!item.ok ? 'button' : undefined} tabindex={!item.ok ? 0 : undefined} onclick={() => !item.ok && onItemClick(item.key, item.tab)} onkeydown={(e) => { if (!item.ok && (e.key === 'Enter' || e.key === ' ')) onItemClick(item.key, item.tab); }}>
|
||||
{#if item.ok}
|
||||
<svg class="w-3.5 h-3.5 shrink-0 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
<span class="text-text-muted truncate">{tLabel(item.key, item.label)}</span>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 shrink-0 {item.required ? 'text-warning' : 'text-text-subtle'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01"/></svg>
|
||||
<span class="{item.required ? 'text-text font-medium' : 'text-text-muted'} truncate underline decoration-dotted underline-offset-2" title={tHint(item.key, item.message)}>{tLabel(item.key, item.label)}</span>
|
||||
{#if item.required}
|
||||
<span class="text-[10px] text-warning shrink-0">*</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if missingRequired.length > 0}
|
||||
<p class="mt-2 text-[11px] text-text-subtle">* — {rr?.required_before_run || 'required before run'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- #endregion PreflightChecklist -->
|
||||
@@ -21,7 +21,7 @@
|
||||
<script lang="ts">
|
||||
import { getT } from '$lib/i18n/index.svelte.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { updateJob, fetchJobMetrics } from '$lib/api/translate.js';
|
||||
import { fetchJobMetrics } from '$lib/api/translate.js';
|
||||
import HelpTooltip from '$lib/ui/HelpTooltip.svelte';
|
||||
import { Icon } from '$lib/ui';
|
||||
import TranslationRunProgress from './TranslationRunProgress.svelte';
|
||||
@@ -36,7 +36,6 @@
|
||||
isFullRun = false,
|
||||
runError = null,
|
||||
currentRunId = null,
|
||||
runComplete = false,
|
||||
completedRuns = [],
|
||||
expandedRunIds = [],
|
||||
runHistoryHasMore = false,
|
||||
@@ -48,7 +47,6 @@
|
||||
onLoadRunHistory = () => {},
|
||||
onLoadMoreRuns = () => {},
|
||||
onToggleRunDetails = (_id) => {},
|
||||
onGoToTargetTab = () => {},
|
||||
onDismissRun = () => {},
|
||||
getJobStatusLabel = (s) => s,
|
||||
} = $props();
|
||||
@@ -88,7 +86,6 @@
|
||||
|
||||
function getRunDisabledReason(): string {
|
||||
if (isRunning) return _t.translate?.run?.disabled_running || 'Translation is already running';
|
||||
if (status === 'DRAFT') return _t.translate?.run?.disabled_draft || 'Mark the job as READY first';
|
||||
if (!schemaValidated) return _t.translate?.run?.disabled_schema || 'Validate target schema first';
|
||||
return '';
|
||||
}
|
||||
@@ -171,19 +168,18 @@
|
||||
return labels[trigger] || trigger || _t.translate?.run?.trigger_manual;
|
||||
}
|
||||
|
||||
async function handleMarkReady() {
|
||||
try {
|
||||
await updateJob(jobId, { status: 'READY' });
|
||||
status = 'READY';
|
||||
addToast(getT()?.translate?.config?.job_updated, 'success');
|
||||
} catch (e) {
|
||||
addToast(e?.message || 'Failed to update status', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
|
||||
// Use getT() + $derived to keep reactivity to locale changes.
|
||||
const _t = $derived(getT());
|
||||
|
||||
const statusClass = $derived(
|
||||
status === 'READY' ? 'bg-success-light text-success'
|
||||
: status === 'ACTIVE' ? 'bg-success-light text-success'
|
||||
: status === 'RUNNING' ? 'bg-primary-light text-primary'
|
||||
: status === 'COMPLETED' ? 'bg-success-light text-success'
|
||||
: status === 'FAILED' ? 'bg-destructive-light text-destructive'
|
||||
: 'bg-surface-muted text-text-subtle'
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="bg-surface-card border border-border rounded-lg p-6">
|
||||
@@ -192,23 +188,9 @@
|
||||
<!-- Status display + transition -->
|
||||
<div class="flex items-center gap-3 mb-4 p-3 bg-surface-muted rounded-lg">
|
||||
<span class="text-sm text-text-muted">{_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-success-light text-success' : ''}
|
||||
{status === 'DRAFT' ? 'bg-warning-light text-warning' : ''}
|
||||
{status === 'ACTIVE' ? 'bg-success-light text-success' : ''}
|
||||
{status === 'RUNNING' ? 'bg-primary-light text-primary' : ''}
|
||||
{status === 'COMPLETED' ? 'bg-success-light text-success' : ''}
|
||||
{status === 'FAILED' ? 'bg-destructive-light text-destructive' : ''}">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {statusClass}">
|
||||
{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">
|
||||
@@ -228,17 +210,12 @@
|
||||
<p class="text-xs text-text-muted mt-1">{_t.translate?.config?.run_incremental_desc}</p>
|
||||
<button
|
||||
onclick={() => confirmRun(false)}
|
||||
disabled={isRunning || status === 'DRAFT' || !schemaValidated}
|
||||
disabled={isRunning || !schemaValidated}
|
||||
title={getRunDisabledReason()}
|
||||
class="mt-3 px-5 py-1.5 text-sm font-medium bg-success text-white rounded-lg hover:bg-success disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isRunning && !isFullRun ? _t.translate?.config?.running : _t.translate?.config?.run_translation}
|
||||
</button>
|
||||
{#if !schemaValidated}
|
||||
<div class="mt-1.5">
|
||||
<button onclick={onGoToTargetTab} class="text-xs text-warning hover:underline cursor-pointer">{_t.translate?.target_schema?.validate_first || 'Please verify target schema first'}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,17 +236,12 @@
|
||||
<p class="text-xs text-text-muted mt-1">{_t.translate?.config?.run_full_desc}</p>
|
||||
<button
|
||||
onclick={() => confirmRun(true)}
|
||||
disabled={isRunning || status === 'DRAFT' || !schemaValidated}
|
||||
disabled={isRunning || !schemaValidated}
|
||||
title={getRunDisabledReason()}
|
||||
class="mt-3 px-5 py-1.5 text-sm font-medium bg-warning text-white rounded-lg hover:bg-warning-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isRunning && isFullRun ? _t.translate?.config?.running : _t.translate?.config?.full_translate}
|
||||
</button>
|
||||
{#if !schemaValidated}
|
||||
<div class="mt-1.5">
|
||||
<button onclick={onGoToTargetTab} class="text-xs text-warning hover:underline cursor-pointer">{_t.translate?.target_schema?.validate_first || 'Please verify target schema first'}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"previous": "Previous",
|
||||
"unknown_error": "An unknown error occurred",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"unknown": "Unknown",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
@@ -58,7 +59,6 @@
|
||||
"target_language_search_placeholder": "Search languages...",
|
||||
"target_language_required": "Select at least one target language",
|
||||
"status": "Status",
|
||||
"status_draft": "Draft",
|
||||
"status_ready": "Ready",
|
||||
"status_active": "Active",
|
||||
"target_column_placeholder": "Target Column",
|
||||
@@ -130,11 +130,10 @@
|
||||
"select_target_database": "Select database for INSERT...",
|
||||
"target_database_hint": "Database connection used for writing translated data via SQL Lab",
|
||||
"loading_databases": "Loading databases...",
|
||||
"include_source_reference": "Include source language in translations",
|
||||
"include_source_reference_hint": "The original text will be stored as a verified reference copy in its detected language",
|
||||
"include_source_reference": "Add the original as a separate record",
|
||||
"include_source_reference_hint": "The target table will receive a copy of the source text with its detected language",
|
||||
"disable_reasoning": "Disable reasoning (save tokens)",
|
||||
"disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning",
|
||||
"mark_ready": "Mark as READY",
|
||||
"save_job_first": "Save the job first",
|
||||
"breadcrumb_job": "Job",
|
||||
"help_name": "A unique job name for quick identification in the list.",
|
||||
@@ -148,7 +147,7 @@
|
||||
"help_target_language": "Languages to translate the text into. You can select multiple — a single LLM call translates each row into ALL selected languages at once. ⚠️ Important: the system auto-detects each source row's language (lingua detector). If the detected language matches one of the target languages, translation for THAT language is SKIPPED — the original text is saved as-is. Example: source 'Hello world' is detected as 'en'. If 'English (en)' is selected as a target, this row is skipped for en (stays 'Hello world') but translated into other selected languages (e.g., 'Привет мир' for ru).",
|
||||
"help_batch_size": "Number of rows sent to the LLM per request. Smaller batches = higher quality but more tokens spent on overhead.",
|
||||
"help_upsert_strategy": "Write strategy for the target table: UPSERT (MERGE) — insert or update; INSERT — new rows only; UPDATE — existing rows only.",
|
||||
"help_include_source_reference": "Save the original text in a separate column as a reference copy. Useful for verification and auditing.",
|
||||
"help_include_source_reference": "When enabled, the result includes both translations and a separate record with the original text and src_lang. This is useful for audit and comparison. When disabled, rows without an actual translation are not inserted.",
|
||||
"help_disable_reasoning": "Disable LLM Chain-of-Thought reasoning. Saves tokens (~20-30%) but may reduce quality for complex translations.",
|
||||
"help_dictionaries": "Terminology dictionaries for forced term mapping. Improves translation consistency within the subject domain.",
|
||||
"help_target_schema": "The database schema (namespace) where translated data will be written. E.g., public.",
|
||||
@@ -253,7 +252,6 @@
|
||||
"subtitle": "Manage your translation jobs",
|
||||
"new_job": "New Job",
|
||||
"create_job": "Create Job",
|
||||
"status_draft": "Draft",
|
||||
"status_ready": "Ready",
|
||||
"status_running": "Running",
|
||||
"status_completed": "Completed",
|
||||
@@ -274,7 +272,30 @@
|
||||
"duplicate_failed": "Failed to duplicate job",
|
||||
"job_deleted": "Job deleted",
|
||||
"delete_failed": "Failed to delete job",
|
||||
"jobs_flow_hint": "Flow: create a job → configure datasource and LLM → run translation → check run history."
|
||||
"jobs_flow_hint": "Flow: create a job → configure datasource and LLM → run translation → check run history.",
|
||||
"run_readiness": {
|
||||
"label_name": "Job name",
|
||||
"hint_name": "Enter a job name",
|
||||
"label_datasource": "Datasource selected",
|
||||
"hint_datasource": "Select a source datasource",
|
||||
"label_translation_column": "Translation column",
|
||||
"hint_translation_column": "Select a column to translate",
|
||||
"label_target_languages": "Target languages",
|
||||
"hint_target_languages": "Select at least one target language",
|
||||
"label_provider": "LLM provider",
|
||||
"hint_provider": "Select an LLM provider",
|
||||
"label_target_schema": "Target schema",
|
||||
"hint_target_schema": "Target schema not set — insert may fail",
|
||||
"label_target_table": "Target table",
|
||||
"hint_target_table": "Target table not set — insert may fail",
|
||||
"label_schema_validated": "Schema validated",
|
||||
"hint_schema_validated": "Validate target schema before first run",
|
||||
"label_connection_id": "DB connection",
|
||||
"hint_connection_id": "Select a Direct DB connection",
|
||||
"missing_required": "required items missing",
|
||||
"ready_to_run": "Ready to run",
|
||||
"required_before_run": "required before run"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "Translation History",
|
||||
@@ -579,7 +600,6 @@
|
||||
"help_sum_total_tokens": "Cumulative LLM token consumption across all runs. Tokens are the unit of input/output processed by the language model.",
|
||||
"help_sum_total_cost": "Estimated cumulative cost of all LLM API calls for this job, calculated from per-run token usage.",
|
||||
"help_sum_avg_duration": "Average duration of a translation run across all runs. Measured from run start to completion (wall-clock time, including LLM calls and DB inserts).",
|
||||
"disabled_draft": "Mark the job as READY first",
|
||||
"disabled_running": "Translation is already running",
|
||||
"disabled_schema": "Validate target schema first",
|
||||
"load_more_runs": "Load more runs",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"previous": "Назад",
|
||||
"unknown_error": "Произошла неизвестная ошибка",
|
||||
"from": "От",
|
||||
"to": "До",
|
||||
"unknown": "Неизвестно",
|
||||
"error": "Ошибка",
|
||||
"success": "Успешно",
|
||||
@@ -58,7 +59,6 @@
|
||||
"target_language_search_placeholder": "Поиск языков...",
|
||||
"target_language_required": "Выберите хотя бы один язык перевода",
|
||||
"status": "Статус",
|
||||
"status_draft": "Черновик",
|
||||
"status_ready": "Готово",
|
||||
"status_active": "Активно",
|
||||
"target_column_placeholder": "Целевая колонка",
|
||||
@@ -130,11 +130,10 @@
|
||||
"select_target_database": "Выберите базу данных для INSERT...",
|
||||
"target_database_hint": "Подключение к БД для записи переведённых данных через SQL Lab",
|
||||
"loading_databases": "Загрузка баз данных...",
|
||||
"include_source_reference": "Сохранять исходный текст как эталонную копию",
|
||||
"include_source_reference_hint": "Исходный текст будет сохранён как верифицированная эталонная копия на его обнаруженном языке",
|
||||
"include_source_reference": "Добавлять оригинал отдельной записью",
|
||||
"include_source_reference_hint": "В целевую таблицу будет записана копия исходного текста с его обнаруженным языком",
|
||||
"disable_reasoning": "Отключить рассуждения (экономия токенов)",
|
||||
"disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)",
|
||||
"mark_ready": "Пометить как готово",
|
||||
"save_job_first": "Сначала сохраните задание",
|
||||
"breadcrumb_job": "Задание",
|
||||
"help_name": "Уникальное название задания для быстрой идентификации в списке.",
|
||||
@@ -148,7 +147,7 @@
|
||||
"help_target_language": "Языки, на которые нужно перевести текст. Можно выбрать несколько — за один проход LLM переведёт строку на все языки сразу. ⚠️ Важно: система автоматически определяет язык каждой исходной строки (lingua-детектор). Если обнаруженный язык совпадает с одним из целевых — для этой строки перевод на данный язык НЕ выполняется, а исходный текст сохраняется как есть. Пример: исходная строка 'Hello world' определена как 'en'. Если 'English (en)' выбран как целевой язык, эта строка будет пропущена для en-перевода (останется 'Hello world'), но будет переведена на остальные выбранные языки (например, 'Привет мир' для ru).",
|
||||
"help_batch_size": "Количество строк, отправляемых в LLM за один запрос. Меньше пакет = выше качество, но больше токенов на оверхед.",
|
||||
"help_upsert_strategy": "Стратегия записи в целевую таблицу: UPSERT (MERGE) — вставка или обновление; INSERT — только новые строки; UPDATE — только обновление существующих.",
|
||||
"help_include_source_reference": "Сохранить оригинальный текст в отдельной колонке как эталонную копию. Полезно для верификации и аудита.",
|
||||
"help_include_source_reference": "Если включено, система добавит в результат не только переводы, но и отдельную запись с исходным текстом и src_lang. Это полезно для аудита и сверки. Если выключить, строки без фактического перевода не будут вставляться.",
|
||||
"help_disable_reasoning": "Отключить Chain-of-Thought рассуждения LLM. Экономит токены (~20-30%), но может снизить качество сложных переводов.",
|
||||
"help_dictionaries": "Терминологические словари для принудительного сопоставления терминов. Повышают консистентность перевода в рамках предметной области.",
|
||||
"help_target_schema": "Схема (namespace) базы данных, в которую будут записаны переведённые данные. Например: public.",
|
||||
@@ -254,7 +253,6 @@
|
||||
"subtitle": "Управление заданиями перевода",
|
||||
"new_job": "Новое задание",
|
||||
"create_job": "Создать задание",
|
||||
"status_draft": "Черновик",
|
||||
"status_ready": "Готово",
|
||||
"status_running": "Выполняется",
|
||||
"status_completed": "Завершено",
|
||||
@@ -275,7 +273,30 @@
|
||||
"duplicate_failed": "Не удалось дублировать задание",
|
||||
"job_deleted": "Задание удалено",
|
||||
"delete_failed": "Не удалось удалить задание",
|
||||
"jobs_flow_hint": "Flow: создайте задание → настройте источник данных и LLM → запустите перевод → проверьте историю запусков."
|
||||
"jobs_flow_hint": "Flow: создайте задание → настройте источник данных и LLM → запустите перевод → проверьте историю запусков.",
|
||||
"run_readiness": {
|
||||
"label_name": "Название",
|
||||
"hint_name": "Введите название задания",
|
||||
"label_datasource": "Источник данных",
|
||||
"hint_datasource": "Выберите источник данных",
|
||||
"label_translation_column": "Колонка для перевода",
|
||||
"hint_translation_column": "Выберите колонку для перевода",
|
||||
"label_target_languages": "Языки перевода",
|
||||
"hint_target_languages": "Выберите хотя бы один целевой язык",
|
||||
"label_provider": "LLM провайдер",
|
||||
"hint_provider": "Выберите LLM провайдера",
|
||||
"label_target_schema": "Целевая схема",
|
||||
"hint_target_schema": "Не указана целевая схема — вставка может не сработать",
|
||||
"label_target_table": "Целевая таблица",
|
||||
"hint_target_table": "Не указана целевая таблица — вставка может не сработать",
|
||||
"label_schema_validated": "Схема проверена",
|
||||
"hint_schema_validated": "Проверьте целевую схему перед первым запуском",
|
||||
"label_connection_id": "Подключение к БД",
|
||||
"hint_connection_id": "Выберите Direct DB подключение",
|
||||
"missing_required": "обязательных полей не заполнено",
|
||||
"ready_to_run": "Готово к запуску",
|
||||
"required_before_run": "обязательно для запуска"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "История переводов",
|
||||
@@ -580,7 +601,6 @@
|
||||
"help_sum_total_tokens": "Суммарное потребление токенов LLM во всех запусках. Токены — единица входных/выходных данных, обрабатываемых языковой моделью.",
|
||||
"help_sum_total_cost": "Оценка суммарной стоимости всех вызовов LLM API для этого задания, рассчитанная на основе использования токенов в каждом запуске.",
|
||||
"help_sum_avg_duration": "Средняя продолжительность запуска перевода по всем запускам. Измеряется от начала до завершения (реальное время, включая вызовы LLM и вставки в БД).",
|
||||
"disabled_draft": "Сначала переведите задание в статус ГОТОВО",
|
||||
"disabled_running": "Перевод уже выполняется",
|
||||
"disabled_schema": "Сначала проверьте схему целевой таблицы",
|
||||
"load_more_runs": "Загрузить ещё запуски",
|
||||
|
||||
@@ -18,10 +18,6 @@
|
||||
// in arrow functions: onTriggerRun={(full) => m.handleTriggerRun(full)}.
|
||||
// @REJECTED Converting methods to arrow class fields rejected — it would conflict with the Svelte 5
|
||||
// `$state` rune initialization order for non-primitive state atoms in the constructor.
|
||||
// @REJECTED includeSourceReference ($state at line 52) has no backend column or schema field.
|
||||
// It is a UI-only checkbox; the value always resets to `true` on page load.
|
||||
// The backend TranslateJobCreate/Update/Response schemas lack `include_source_reference`.
|
||||
// Requires a DB migration + Pydantic schema update to persist. Not implemented as of 2026-06-03.
|
||||
import { api } from '$lib/api.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { _, getT } from '$lib/i18n/index.svelte.js';
|
||||
@@ -30,6 +26,15 @@ import { startTranslationRun, resetTranslationRun, translationRunStore } from '$
|
||||
|
||||
type UxState = 'idle' | 'loading' | 'configured' | 'saving' | 'validation_error' | 'datasource_unavailable';
|
||||
|
||||
type ReadinessItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
ok: boolean;
|
||||
required: boolean;
|
||||
message: string;
|
||||
tab: string; // 'config' | 'target' — which tab to navigate to on click
|
||||
};
|
||||
|
||||
export class TranslationJobModel {
|
||||
// ── Context ───────────────────────────────────────────────────
|
||||
uxState: UxState = $state('idle');
|
||||
@@ -99,7 +104,35 @@ export class TranslationJobModel {
|
||||
isSaving: boolean = $state(false);
|
||||
isDirty: boolean = $state(false);
|
||||
validationErrors: Record<string, unknown> = $state({});
|
||||
warnings: string[] = $state([]);
|
||||
|
||||
/** Run readiness checklist — derived from current model state */
|
||||
runReadiness: ReadinessItem[] = $derived.by(() => {
|
||||
const items: ReadinessItem[] = [
|
||||
{ key: 'name', label: 'Job name', ok: !!this.name, required: true, message: 'Enter a job name', tab: 'config' },
|
||||
{ key: 'datasource', label: 'Datasource selected', ok: !!this.datasourceId, required: true, message: 'Select a source datasource', tab: 'config' },
|
||||
{ key: 'translationColumn', label: 'Translation column', ok: !!this.translationColumn, required: true, message: 'Select a column to translate', tab: 'config' },
|
||||
{ key: 'targetLanguages', label: 'Target languages', ok: this.targetLanguages.length > 0, required: true, message: 'Select at least one target language', tab: 'config' },
|
||||
{ key: 'provider', label: 'LLM provider', ok: !!this.providerId, required: true, message: 'Select an LLM provider', tab: 'config' },
|
||||
{ key: 'targetSchema', label: 'Target schema', ok: !!this.targetSchema, required: false, message: 'Target schema not set — insert may fail', tab: 'target' },
|
||||
{ key: 'targetTable', label: 'Target table', ok: !!this.targetTable, required: false, message: 'Target table not set — insert may fail', tab: 'target' },
|
||||
{ key: 'schemaValidated', label: 'Target schema validated', ok: this.schemaValidated, required: false, message: 'Validate target schema before first run', tab: 'target' },
|
||||
];
|
||||
if (this.insertMethod === 'direct_db') {
|
||||
items.push({ key: 'connectionId', label: 'DB connection', ok: !!this.connectionId, required: true, message: 'Select a Direct DB connection', tab: 'target' });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
/** True when all REQUIRED readiness items pass */
|
||||
runReady: boolean = $derived(this.runReadiness.filter(i => i.required).every(i => i.ok));
|
||||
|
||||
/** Warnings derived from readiness items */
|
||||
warnings: string[] = $derived.by(() => {
|
||||
const w: string[] = [];
|
||||
const missing = this.runReadiness.filter(i => !i.ok && !i.required);
|
||||
for (const item of missing) w.push(item.message);
|
||||
return w;
|
||||
});
|
||||
|
||||
configValid = $derived(
|
||||
!!this.translationColumn && !!this.datasourceId && this.targetLanguages.length > 0 && !!this.providerId
|
||||
@@ -127,15 +160,22 @@ export class TranslationJobModel {
|
||||
// ── Actions: Run ─────────────────────────────────────────────
|
||||
|
||||
async handleTriggerRun(full = false): Promise<void> {
|
||||
this.runError = '';
|
||||
// Auto-save any unsaved config changes before triggering a run
|
||||
if (!this.isNewJob) {
|
||||
try {
|
||||
await this.saveJob();
|
||||
} catch (err: unknown) {
|
||||
this.runError = err instanceof Error ? err.message : _('translate.config.run_failed');
|
||||
this.isRunning = false;
|
||||
addToast(this.runError, 'error');
|
||||
return; // don't proceed if auto-save fails
|
||||
}
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.runComplete = false;
|
||||
this.isFullRun = full;
|
||||
this.runError = '';
|
||||
try {
|
||||
// Auto-save any unsaved config changes before triggering a run
|
||||
if (!this.isNewJob) {
|
||||
await this.saveJob();
|
||||
}
|
||||
const run = await triggerRun(this.jobId, full);
|
||||
startTranslationRun(run.id, { jobId: this.jobId, isFullRun: full, onComplete: this._onRunComplete.bind(this) });
|
||||
addToast(full ? 'Полный перевод запущен (все строки)' : _('translate.config.run_started'), 'success');
|
||||
@@ -252,10 +292,16 @@ export class TranslationJobModel {
|
||||
this.upsertStrategy = (job.upsert_strategy as string) || 'MERGE';
|
||||
this.insertMethod = (job.insert_method as string) || 'sqllab';
|
||||
this.connectionId = (job.connection_id as string) || null;
|
||||
this.includeSourceReference = (job.include_source_reference as boolean) ?? true;
|
||||
this.disableReasoning = (job.disable_reasoning as boolean) ?? false;
|
||||
this.databaseDialect = (job.database_dialect as string) || '';
|
||||
this.datasourceId = (job.source_datasource_id as string) || '';
|
||||
this.status = (job.status as string) || 'DRAFT';
|
||||
if (this.runReady && this.status === 'DRAFT') {
|
||||
this.status = 'READY';
|
||||
} else if (!this.runReady && this.status === 'READY') {
|
||||
this.status = 'DRAFT';
|
||||
}
|
||||
this.targetSchema = (job.target_schema as string) || '';
|
||||
this.targetTable = (job.target_table as string) || '';
|
||||
this.targetDatabaseId = (job.target_database_id as string) || '';
|
||||
@@ -320,6 +366,11 @@ export class TranslationJobModel {
|
||||
this.validationErrors = {};
|
||||
this.isDirty = false;
|
||||
try {
|
||||
if (this.runReady && this.status === 'DRAFT') {
|
||||
this.status = 'READY';
|
||||
} else if (!this.runReady && this.status === 'READY') {
|
||||
this.status = 'DRAFT';
|
||||
}
|
||||
const payload = {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
@@ -337,6 +388,7 @@ export class TranslationJobModel {
|
||||
upsert_strategy: this.upsertStrategy,
|
||||
insert_method: this.insertMethod,
|
||||
connection_id: this.connectionId || undefined,
|
||||
include_source_reference: this.includeSourceReference,
|
||||
disable_reasoning: this.disableReasoning,
|
||||
database_dialect: this.databaseDialect && this.databaseDialect !== 'unknown' ? this.databaseDialect : undefined,
|
||||
target_schema: this.targetSchema || undefined,
|
||||
@@ -360,6 +412,38 @@ export class TranslationJobModel {
|
||||
this.uxState = 'configured';
|
||||
} catch (err: unknown) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to save';
|
||||
// Parse structured Pydantic 422 errors into per-field validationErrors
|
||||
const apiErr = err as Record<string, unknown>;
|
||||
if (Array.isArray(apiErr.detail)) {
|
||||
const FIELD_MAP: Record<string, string> = {
|
||||
'name': 'name',
|
||||
'translation_column': 'translationColumn',
|
||||
'source_datasource_id': 'datasourceId',
|
||||
'target_languages': 'targetLanguages',
|
||||
'provider_id': 'providerId',
|
||||
'target_schema': 'targetSchema',
|
||||
'target_table': 'targetTable',
|
||||
'source_table': 'sourceTable',
|
||||
'target_column': 'targetColumn',
|
||||
'batch_size': 'batchSize',
|
||||
'upsert_strategy': 'upsertStrategy',
|
||||
'insert_method': 'insertMethod',
|
||||
'connection_id': 'connectionId',
|
||||
'environment_id': 'environmentId',
|
||||
'target_database_id': 'targetDatabaseId',
|
||||
'target_language_column': 'targetLanguageColumn',
|
||||
'target_source_column': 'targetSourceColumn',
|
||||
'target_source_language_column': 'targetSourceLanguageColumn',
|
||||
};
|
||||
for (const detail of apiErr.detail as Array<Record<string, unknown>>) {
|
||||
const loc = detail.loc as string[] | undefined;
|
||||
if (loc && loc.length > 0) {
|
||||
const snakeField = loc[loc.length - 1];
|
||||
const camelField = FIELD_MAP[snakeField] || snakeField;
|
||||
this.validationErrors[camelField] = detail.msg as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
addToast(this.error, 'error');
|
||||
this.uxState = 'validation_error';
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
// Count jobs by status for filter pills
|
||||
let statusCounts = $derived.by(() => {
|
||||
const counts = { DRAFT: 0, READY: 0, RUNNING: 0, COMPLETED: 0, FAILED: 0, CANCELLED: 0 };
|
||||
const counts = { READY: 0, RUNNING: 0, COMPLETED: 0, FAILED: 0 };
|
||||
for (const job of jobs) {
|
||||
if (counts[job.status] !== undefined) counts[job.status]++;
|
||||
}
|
||||
@@ -43,7 +43,6 @@
|
||||
|
||||
let statusPills = $derived([
|
||||
{ label: 'All', value: '', count: jobs.length },
|
||||
{ label: $t.translate?.jobs?.status_draft, value: 'DRAFT', count: statusCounts.DRAFT },
|
||||
{ label: $t.translate?.jobs?.status_ready, value: 'READY', count: statusCounts.READY },
|
||||
{ label: $t.translate?.jobs?.status_running, value: 'RUNNING', count: statusCounts.RUNNING },
|
||||
{ label: $t.translate?.jobs?.status_completed, value: 'COMPLETED', count: statusCounts.COMPLETED },
|
||||
@@ -196,7 +195,7 @@
|
||||
{:else if uxState === 'populated'}
|
||||
<div class="grid gap-4">
|
||||
{#each jobs as job}
|
||||
{@const statusVariant = ({ DRAFT: "muted", READY: "primary", RUNNING: "warning", COMPLETED: "success", FAILED: "destructive", CANCELLED: "muted" })[job.status] || "muted"}
|
||||
{@const statusVariant = ({ READY: "primary", RUNNING: "warning", COMPLETED: "success", FAILED: "destructive", CANCELLED: "muted" })[job.status] || "muted"}
|
||||
<div
|
||||
onclick={() => navigateToConfig(job.id)}
|
||||
class="bg-surface-card border border-border rounded-lg p-4 hover:shadow-md hover:border-border-strong transition-all cursor-pointer"
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
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 PreflightChecklist from '$lib/components/translate/PreflightChecklist.svelte';
|
||||
|
||||
const m = new TranslationJobModel();
|
||||
// @RATIONALE Svelte 5 compiler fails to detect `t` as store in deeply nested
|
||||
@@ -101,18 +102,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
{#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 m.warnings as w}
|
||||
<li>{w}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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"></div>{/each}</div>
|
||||
@@ -132,6 +121,9 @@
|
||||
|
||||
<!-- Form -->
|
||||
{:else if m.uxState === 'configured' || m.uxState === 'saving' || m.uxState === 'validation_error' || m.uxState === 'idle'}
|
||||
<!-- Preflight checklist — always visible at top -->
|
||||
<PreflightChecklist runReadiness={m.runReadiness} onItemClick={(key, tab) => { m.activeTab = tab; }} />
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-border mb-6">
|
||||
<nav class="flex gap-1" aria-label="Tabs">
|
||||
@@ -268,7 +260,6 @@
|
||||
isFullRun={m.isFullRun}
|
||||
runError={m.runError}
|
||||
currentRunId={m.currentRunId}
|
||||
runComplete={m.runComplete}
|
||||
completedRuns={m.completedRuns}
|
||||
expandedRunIds={m.expandedRunIds}
|
||||
runHistoryHasMore={m.runHistoryHasMore}
|
||||
@@ -280,7 +271,6 @@
|
||||
onLoadRunHistory={() => m.loadRunHistory()}
|
||||
onLoadMoreRuns={() => m.loadRunHistory(true)}
|
||||
onToggleRunDetails={(id) => m.toggleRunDetails(id)}
|
||||
onGoToTargetTab={() => m.activeTab = 'target'}
|
||||
onDismissRun={() => m.handleDismissRun()}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user