fix(translate): complete i18n for run page UX improvements

- Add missing i18n keys to ru/en translate.json:
  run: disabled_draft, disabled_running, disabled_schema,
       load_more_runs, correct, dismiss, download_csv, insert_method
  preview: translation
  schedule: delete_confirm
  bulk_replace: confirm_count_input, dictionary,
                select_dictionary, word_boundary_hint
  run filters: records_filter_all/success/failed/skipped

- Add auto-expand for failed/partial runs in TranslationJobModel
- Add typed-count confirmation for bulk replace >100 changes
- Add word-boundary hint in BulkReplaceModal
- Add auto-select target language when only one exists
- Add delete confirmation in ScheduleConfig
- Add Escape/Enter keyboard handling for run dialog
- Replace emoji with Icon components in CorrectionCell
- Use semantic Tailwind tokens for insert-method badge
- Add run start date/time and pagination to run history
- Keep TranslationRunProgress visible after run completion
This commit is contained in:
2026-06-19 09:55:43 +03:00
parent 9aa2720f26
commit b3efd62dd1
10 changed files with 263 additions and 42 deletions

View File

@@ -45,6 +45,8 @@
let usageNotes = $state('');
let dictionaries = $state([]);
let selectedDictId = $state('');
let confirmCountInput = $state('');
const LARGE_CHANGE_THRESHOLD = 100;
async function loadDictionaries() {
try {
@@ -64,7 +66,7 @@
findPattern = '';
replacementText = '';
isRegex = false;
targetLanguage = '';
targetLanguage = targetLanguages.length === 1 ? targetLanguages[0] : '';
affectedRecords = [];
applyCount = 0;
errorMessage = '';
@@ -72,6 +74,7 @@
usageNotes = '';
dictionaries = [];
selectedDictId = '';
confirmCountInput = '';
} else {
uxState = 'closed';
}
@@ -109,6 +112,7 @@
/** Open confirmation */
function handleShowConfirm() {
uxState = 'confirming';
confirmCountInput = '';
loadDictionaries();
}
@@ -216,6 +220,11 @@
<span class="text-text-muted text-xs">{_t.translate?.bulk_replace?.regex}</span>
</label>
</div>
{#if findPattern.trim() && !isRegex && !findPattern.includes('\\b')}
<p class="text-xs text-info mt-1">
{_t.translate?.bulk_replace?.word_boundary_hint || 'Tip: use \\bword\\b to match whole words only'}
</p>
{/if}
</div>
<!-- Replacement Text -->
@@ -356,6 +365,21 @@
{/if}
</div>
{#if applyCount > LARGE_CHANGE_THRESHOLD}
<div class="mt-3">
<label class="block text-xs font-medium text-text mb-1" for="bulk-confirm-count">
{_t.translate?.bulk_replace?.confirm_count_input?.replace('{count}', applyCount) || `Type ${applyCount} to confirm`}
</label>
<input
id="bulk-confirm-count"
type="text"
bind:value={confirmCountInput}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm font-mono"
placeholder={String(applyCount)}
/>
</div>
{/if}
<div class="flex gap-2 mt-3">
<button
onclick={handleCancelConfirm}
@@ -365,7 +389,8 @@
</button>
<button
onclick={handleApply}
class="px-3 py-1.5 text-sm bg-warning text-white rounded-lg hover:bg-warning transition-colors"
disabled={applyCount > LARGE_CHANGE_THRESHOLD && confirmCountInput !== String(applyCount)}
class="px-3 py-1.5 text-sm bg-warning text-white rounded-lg hover:bg-warning disabled:opacity-50 transition-colors"
>
{_t.translate?.bulk_replace?.apply_count.replace('{count}', applyCount)}
</button>

View File

@@ -17,6 +17,7 @@
import { addToast } from '$lib/toasts.svelte.js';
import { inlineEditCorrection, dictionaryApi } from '$lib/api/translate.js';
import { _, getT } from '$lib/i18n/index.svelte.js';
import { Icon } from '$lib/ui';
/** @type {{ value: string, recordId: string, languageCode: string, runId: string, sourceLanguage?: string, contextData?: object|null, usageNotes?: string, onSave?: (newValue: string) => void }} */
let {
@@ -205,14 +206,15 @@
{:else if uxState === 'saved'}
<div class="flex items-center gap-2">
<span class="text-sm text-text">{displayValue}</span>
<span class="text-xs text-success" title={_t.translate?.corrections?.cell_save_success} aria-label={_t.translate?.corrections?.cell_save_success} role="img">📝</span>
<Icon name="edit" size={12} class="text-success" title={_t.translate?.corrections?.cell_save_success} />
<button
onclick={openDictPopup}
class="ml-1 px-1.5 py-0.5 text-xs bg-warning-light text-warning rounded hover:bg-warning-light transition-colors"
class="ml-1 px-1.5 py-0.5 text-xs bg-warning-light text-warning rounded hover:bg-warning-light transition-colors flex items-center gap-1"
title={_t.translate?.corrections?.cell_submit_to_dict}
aria-label={_t.translate?.corrections?.cell_submit_to_dict}
>
📕 {_t.translate?.corrections?.cell_submit_to_dict}
<Icon name="book" size={12} />
{_t.translate?.corrections?.cell_submit_to_dict}
</button>
</div>
@@ -227,7 +229,10 @@
{:else if uxState === 'dict_submitted'}
<div class="flex items-center gap-2">
<span class="text-sm text-text">{displayValue}</span>
<span class="text-xs text-success font-medium" title={_t.translate?.corrections?.cell_dict_submitted} aria-label={_t.translate?.corrections?.cell_dict_submitted} role="img">{_t.translate?.corrections?.cell_submitted_badge}</span>
<span class="text-xs text-success font-medium flex items-center gap-1" title={_t.translate?.corrections?.cell_dict_submitted} aria-label={_t.translate?.corrections?.cell_dict_submitted}>
<Icon name="check" size={12} />
{_t.translate?.corrections?.cell_submitted_badge}
</span>
</div>
<!-- Error mode -->

View File

@@ -37,14 +37,17 @@
runComplete = false,
completedRuns = [],
expandedRunIds = [],
runHistoryHasMore = false,
showPageBulkReplace = $bindable(false),
schemaValidated = false,
onTriggerRun = (_isFull) => {},
onRetryRun = () => {},
onRetryInsert = () => {},
onLoadRunHistory = () => {},
onLoadMoreRuns = () => {},
onToggleRunDetails = (_id) => {},
onGoToTargetTab = () => {},
onDismissRun = () => {},
getJobStatusLabel = (s) => s,
} = $props();
@@ -81,6 +84,13 @@
return 'text-destructive bg-destructive-light';
}
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 '';
}
/** Copy run ID to clipboard */
async function copyRunId(runId: string) {
try {
@@ -136,6 +146,18 @@
return calcDuration((end - start) / 1000);
}
function formatRunDate(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getTriggerLabel(trigger) {
const labels = {
manual: _t.translate?.run?.trigger_manual,
@@ -204,6 +226,7 @@
<button
onclick={() => confirmRun(false)}
disabled={isRunning || status === 'DRAFT' || !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}
@@ -234,6 +257,7 @@
<button
onclick={() => confirmRun(true)}
disabled={isRunning || status === 'DRAFT' || !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}
@@ -254,11 +278,12 @@
</div>
{/if}
{#if currentRunId && !runComplete}
{#if currentRunId}
<TranslationRunProgress
runId={currentRunId}
onRetry={onRetryRun}
onRetryInsert={onRetryInsert}
onDismiss={onDismissRun}
/>
{/if}
@@ -316,7 +341,7 @@
{_t.translate?.run?.sum_total_cost}
<HelpTooltip text={_t.translate?.run?.help_sum_total_cost || ''} />
</p>
<p class="text-lg font-bold text-text">${(jobMetrics.cumulative_cost || 0).toFixed(2)}</p>
<p class="text-lg font-bold text-text">{jobMetrics.cumulative_cost != null ? `$${(jobMetrics.cumulative_cost).toFixed(2)}` : '—'}</p>
</div>
<div class="bg-primary-light rounded p-2.5">
<p class="text-xs text-primary flex items-center gap-1">
@@ -382,6 +407,11 @@
{getTriggerLabel(run.trigger_type)}
</span>
{/if}
{#if run.started_at}
<span class="text-[11px] text-text-subtle" title={new Date(run.started_at).toLocaleString()}>
{formatRunDate(run.started_at)}
</span>
{/if}
{#if run.started_at}
<span class="text-[11px] text-text-subtle">{_t.translate?.run?.duration_label?.replace('{seconds}', getRunDuration(run))}</span>
{/if}
@@ -417,6 +447,14 @@
{/if}
</div>
{/each}
{#if runHistoryHasMore}
<button
onclick={onLoadMoreRuns}
class="w-full px-4 py-2 text-sm border border-border-strong text-text-muted rounded-lg hover:bg-surface-muted transition-colors"
>
{_t.translate?.run?.load_more_runs || 'Load more runs'}
</button>
{/if}
</div>
</div>
{/if}
@@ -424,8 +462,18 @@
<!-- Confirmation Dialog -->
{#if showConfirmDialog}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick={() => showConfirmDialog = false}>
<div class="bg-surface-card border border-border rounded-xl shadow-xl max-w-md w-full mx-4 p-6" onclick={(e) => e.stopPropagation()}>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onclick={(e) => { if (e.target === e.currentTarget) showConfirmDialog = false; }}
onkeydown={(e) => {
if (e.key === 'Escape') showConfirmDialog = false;
if (e.key === 'Enter') executeConfirmedRun();
}}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="bg-surface-card border border-border rounded-xl shadow-xl max-w-md w-full mx-4 p-6" role="document">
<h3 class="text-lg font-semibold text-text mb-2">
{pendingRunFull
? (_t.translate?.config?.confirm_full_run_title || 'Run full translation?')

View File

@@ -93,6 +93,10 @@
}
async function handleDelete() {
const confirmed = typeof window !== 'undefined' && window.confirm(
(getT()?.translate?.schedule?.delete_confirm || 'Delete this schedule permanently?')
);
if (!confirmed) return;
try {
await deleteSchedule(jobId);
addToast(getT()?.translate?.schedule?.deleted || 'Schedule deleted', 'success');

View File

@@ -39,8 +39,8 @@
import { cancelRun, downloadFailedCsv } from '$lib/api/translate.js';
import { translationRunStore } from '$lib/stores/translationRun.svelte.js';
/** @type {{ runId: string, onRetry?: () => void, onRetryInsert?: () => void, onComplete?: (statusData: any) => void }} */
let { runId, onRetry = () => {}, onRetryInsert = () => {}, onComplete = () => {} } = $props();
/** @type {{ runId: string, onRetry?: () => void, onRetryInsert?: () => void, onComplete?: (statusData: any) => void, onDismiss?: () => void }} */
let { runId, onRetry = () => {}, onRetryInsert = () => {}, onComplete = () => {}, onDismiss = () => {} } = $props();
let isCancelling = $state(false);
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
@@ -165,9 +165,17 @@
<!-- Completed -->
{:else if uxState === 'completed'}
<div class="bg-success-light border border-success rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-success text-lg">&#10003;</span>
<span class="text-sm font-semibold text-success">{_t.translate?.run?.completed}</span>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-success text-lg">&#10003;</span>
<span class="text-sm font-semibold text-success">{_t.translate?.run?.completed}</span>
</div>
<button
onclick={onDismiss}
class="text-xs text-text-muted hover:text-text px-2 py-1 rounded hover:bg-success-light"
>
{_t.translate?.run?.dismiss || '× Close'}
</button>
</div>
<div class="grid grid-cols-3 gap-3 text-sm">
<div>
@@ -193,12 +201,20 @@
<span class="text-warning text-lg">&#9888;</span>
<span class="text-sm font-semibold text-warning">{_t.translate?.run?.completed_with_errors}</span>
</div>
<button
onclick={onRetry}
class="px-3 py-1 text-xs bg-warning-light text-warning rounded hover:bg-yellow-200 transition-colors"
>
{_t.translate?.run?.retry_failed}
</button>
<div class="flex items-center gap-2">
<button
onclick={onRetry}
class="px-3 py-1 text-xs bg-warning-light text-warning rounded hover:bg-yellow-200 transition-colors"
>
{_t.translate?.run?.retry_failed}
</button>
<button
onclick={onDismiss}
class="text-xs text-text-muted hover:text-text px-2 py-1 rounded hover:bg-warning-light"
>
{_t.translate?.run?.dismiss || '× Close'}
</button>
</div>
</div>
<div class="grid grid-cols-3 gap-3 text-sm">
<div>
@@ -224,12 +240,20 @@
<span class="text-destructive text-lg">&#10007;</span>
<span class="text-sm font-semibold text-destructive">{_t.translate?.run?.translation_failed}</span>
</div>
<button
onclick={onRetry}
class="px-3 py-1 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors"
>
{_t.translate?.common?.retry}
</button>
<div class="flex items-center gap-2">
<button
onclick={onRetry}
class="px-3 py-1 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors"
>
{_t.translate?.common?.retry}
</button>
<button
onclick={onDismiss}
class="text-xs text-text-muted hover:text-text px-2 py-1 rounded hover:bg-destructive-light"
>
{_t.translate?.run?.dismiss || '× Close'}
</button>
</div>
</div>
<p class="text-xs text-destructive">{status?.error_message || _t.translate?.common?.unknown}</p>
</div>
@@ -258,6 +282,12 @@
>
{_t.translate?.run?.retry_insert}
</button>
<button
onclick={onDismiss}
class="text-xs text-text-muted hover:text-text px-2 py-1 rounded hover:bg-warning-light"
>
{_t.translate?.run?.dismiss || '× Close'}
</button>
</div>
</div>
<p class="text-xs text-text-muted mt-1">
@@ -268,9 +298,17 @@
<!-- Cancelled -->
{:else if uxState === 'cancelled'}
<div class="bg-surface-muted border border-border rounded-lg p-4">
<div class="flex items-center gap-2">
<span class="text-text-muted text-lg">&#9632;</span>
<span class="text-sm font-semibold text-text">{_t.translate?.run?.cancelled}</span>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-text-muted text-lg">&#9632;</span>
<span class="text-sm font-semibold text-text">{_t.translate?.run?.cancelled}</span>
</div>
<button
onclick={onDismiss}
class="text-xs text-text-muted hover:text-text px-2 py-1 rounded hover:bg-surface-muted"
>
{_t.translate?.run?.dismiss || '× Close'}
</button>
</div>
</div>
{/if}

View File

@@ -41,6 +41,7 @@ import { SvelteSet } from "svelte/reactivity";
let status = $state(null);
let records = $state([]);
let showRecords = $state(false);
let recordStatusFilter = $state('all');
let isRetrying = $state(false);
let isRetryingInsert = $state(false);
let targetLanguages = $state([]);
@@ -66,6 +67,17 @@ import { SvelteSet } from "svelte/reactivity";
return labels[value] || value;
}
let filteredRecords = $derived.by(() => {
if (!records || records.length === 0) return [];
if (recordStatusFilter === 'all') return records;
return records.filter((r) => {
if (recordStatusFilter === 'success') return r.status === 'SUCCESS';
if (recordStatusFilter === 'failed') return r.status === 'FAILED';
if (recordStatusFilter === 'skipped') return r.status === 'SKIPPED';
return true;
});
});
// Derived
let insertStatusBadge = $derived.by(() => {
const s = status?.insert_status;
@@ -79,11 +91,11 @@ import { SvelteSet } from "svelte/reactivity";
if (method === 'direct_db') {
return {
label: 'Direct DB',
class: 'bg-blue-100 text-blue-800',
class: 'bg-primary-light text-primary',
connectionName: status?.connection_snapshot?.name || '',
};
}
return { label: 'SQL Lab', class: 'bg-gray-100 text-gray-800', connectionName: '' };
return { label: 'SQL Lab', class: 'bg-surface-muted text-text-muted', connectionName: '' };
});
$effect(() => {
@@ -133,6 +145,10 @@ import { SvelteSet } from "svelte/reactivity";
} else {
uxState = 'completed';
}
// Auto-expand records when there are failures so user can inspect immediately
if (uxState === 'failed' || uxState === 'partial') {
showRecords = true;
}
} catch (err) {
addToast(err?.message || getT()?.translate?.run?.load_failed, 'error');
}
@@ -470,6 +486,25 @@ import { SvelteSet } from "svelte/reactivity";
</div>
{#if showRecords}
<div class="flex flex-wrap gap-2 mb-3">
{#each ['all','success','failed','skipped'] as filter}
<button
onclick={() => recordStatusFilter = filter}
class="px-2.5 py-1 text-xs rounded-full border transition-colors
{recordStatusFilter === filter
? 'bg-primary-light border-primary-ring text-primary'
: 'bg-surface-card border-border text-text-muted hover:border-border-strong'}"
>
{_t.translate?.run?.['records_filter_' + filter] || filter}
{#if filter !== 'all'}
<span class="ml-1 text-text-subtle">
({records.filter((r) => (filter === 'success' ? r.status === 'SUCCESS' : filter === 'failed' ? r.status === 'FAILED' : r.status === 'SKIPPED')).length})
</span>
{/if}
</button>
{/each}
</div>
<div class="overflow-x-auto border border-border rounded-lg">
<table class="w-full text-sm">
<thead class="bg-surface-muted">
@@ -491,11 +526,11 @@ import { SvelteSet } from "svelte/reactivity";
</tr>
</thead>
<tbody class="divide-y divide-border">
{#each records as row, idx}
{#each filteredRecords as row, idx}
<tr class="{row.status === 'FAILED' ? 'bg-destructive-light' : idx % 2 === 0 ? 'bg-surface-card' : 'bg-surface-muted/50'}">
<td class="px-3 py-2 text-xs text-text-subtle align-top">{idx + 1}</td>
<td class="px-3 py-2 align-top max-w-[240px]">
<div class="text-xs text-text break-all">{row.source_sql || row.source_object_name || '—'}</div>
<div class="text-xs text-text break-all line-clamp-3">{row.source_sql || row.source_object_name || '—'}</div>
{#if row.source_data && typeof row.source_data === 'object'}
<div class="text-[10px] text-text-subtle mt-0.5 font-mono truncate" title={JSON.stringify(row.source_data)}>
{Object.values(row.source_data).filter(Boolean).join(' · ')}
@@ -565,7 +600,7 @@ import { SvelteSet } from "svelte/reactivity";
{#if recordsTotal > 0}
<p class="text-xs text-text-subtle mt-1">
{(_t.translate?.run?.showing_records || 'Showing {count} of {total} unique rows.')
.replace('{count}', records.length)
.replace('{count}', filteredRecords.length)
.replace('{total}', recordsTotal)}
</p>
{/if}

View File

@@ -213,6 +213,7 @@
"re_run": "Re-run",
"table_number": "#",
"table_source": "Source SQL",
"translation": "Translation",
"table_translation": "Translation",
"table_status": "Status",
"table_actions": "Actions",
@@ -336,6 +337,7 @@
"edit": "Edit",
"update": "Update",
"create": "Create",
"delete_confirm": "Delete this schedule permanently?",
"delete_schedule": "Delete Schedule",
"no_prior_run_warning": "No prior successful manual run. Schedule may run with default settings. Consider running manually first.",
"saved": "Schedule saved",
@@ -539,6 +541,10 @@
"started_no": "No",
"terminal_events": "Terminal events: {count}",
"per_language": "Per-Language Statistics",
"records_filter_all": "All",
"records_filter_success": "Success",
"records_filter_failed": "Failed",
"records_filter_skipped": "Skipped",
"records_title": "Translation Records",
"translated_label": "{count} translated",
"failed_label": "{count} failed",
@@ -573,6 +579,14 @@
"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",
"correct": "Correct",
"dismiss": "× Close",
"download_csv": "CSV",
"insert_method": "Insert Method",
"show_details": "Show details",
"hide_details": "Hide details"
},
@@ -651,6 +665,10 @@
"applied_body": "{count} record(s) were updated",
"done": "Done",
"retry": "Retry",
"confirm_count_input": "Type {count} to confirm",
"dictionary": "Dictionary",
"select_dictionary": "Choose dictionary...",
"word_boundary_hint": "Tip: use \\bword\\b to match whole words only",
"applied_toast": "Bulk replace applied to {count} records",
"preview_failed": "Failed to preview replacements",
"apply_failed": "Failed to apply bulk replace",

View File

@@ -213,6 +213,7 @@
"re_run": "Запустить заново",
"table_number": "№",
"table_source": "Исходный SQL",
"translation": "Перевод",
"table_translation": "Перевод",
"table_status": "Статус",
"table_actions": "Действия",
@@ -337,6 +338,7 @@
"edit": "Редактировать",
"update": "Обновить",
"create": "Создать",
"delete_confirm": "Удалить это расписание навсегда?",
"delete_schedule": "Удалить расписание",
"no_prior_run_warning": "Не было успешного ручного запуска. Расписание может работать с настройками по умолчанию. Рекомендуется сначала запустить вручную.",
"saved": "Расписание сохранено",
@@ -540,6 +542,10 @@
"started_no": "Нет",
"terminal_events": "Терминальных событий: {count}",
"per_language": "Статистика по языкам",
"records_filter_all": "Все",
"records_filter_success": "Успешно",
"records_filter_failed": "Ошибки",
"records_filter_skipped": "Пропущено",
"records_title": "Записи перевода",
"translated_label": "{count} переведено",
"failed_label": "{count} с ошибками",
@@ -574,6 +580,14 @@
"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": "Загрузить ещё запуски",
"correct": "Исправить",
"dismiss": "× Закрыть",
"download_csv": "CSV",
"insert_method": "Метод вставки",
"show_details": "Показать детали",
"hide_details": "Скрыть детали"
},
@@ -652,6 +666,10 @@
"applied_body": "{count} запись(ей) обновлено",
"done": "Готово",
"retry": "Повторить",
"confirm_count_input": "Введите {count} для подтверждения",
"dictionary": "Словарь",
"select_dictionary": "Выберите словарь...",
"word_boundary_hint": "Подсказка: используйте \\bслово\\b для поиска только целых слов",
"applied_toast": "Массовая замена применена к {count} записям",
"preview_failed": "Не удалось загрузить предпросмотр замен",
"apply_failed": "Не удалось применить массовую замену",

View File

@@ -116,6 +116,8 @@ export class TranslationJobModel {
currentRunId = $derived(translationRunStore.value?.runId || null);
completedRuns: Record<string, unknown>[] = $state([]);
expandedRunIds: string[] = $state([]);
runHistoryPage: number = $state(1);
runHistoryHasMore: boolean = $state(false);
isRunning: boolean = $state(false);
isFullRun: boolean = $state(false);
runError: string = $state('');
@@ -154,14 +156,33 @@ export class TranslationJobModel {
addToast(`${_('translate.run.run_id')} ${finishedRunId}`, 'info');
}
async loadRunHistory(): Promise<void> {
async loadRunHistory(append = false): Promise<void> {
try {
const data = await fetchRunHistory(this.jobId, { page_size: 5 });
this.completedRuns = ((data as { items?: Record<string, unknown>[] })?.items || []).filter(
const pageSize = 5;
const page = append ? this.runHistoryPage + 1 : 1;
const data = await fetchRunHistory(this.jobId, { page, page_size: pageSize });
const items = ((data as { items?: Record<string, unknown>[] })?.items || []).filter(
(run: Record<string, unknown>) => run && typeof run.id === 'string' && (run.id as string).trim().length > 0
);
this.expandedRunIds = this.expandedRunIds.filter((runId) => this.completedRuns.some((run: Record<string, unknown>) => run.id === runId));
} catch { this.completedRuns = []; this.expandedRunIds = []; }
this.runHistoryHasMore = items.length === pageSize;
if (append) {
this.completedRuns = [...this.completedRuns, ...items];
this.runHistoryPage = page;
} else {
this.completedRuns = items;
this.runHistoryPage = 1;
}
// Auto-expand failed or partial runs so the user immediately sees the error details
const failedRunIds = this.completedRuns
.filter((run: Record<string, unknown>) => run.status === 'FAILED' || (run.status === 'COMPLETED' && (run.failed_records || 0) > 0))
.map((run: Record<string, unknown>) => run.id as string);
this.expandedRunIds = [
...new Set([
...this.expandedRunIds.filter((runId) => this.completedRuns.some((run: Record<string, unknown>) => run.id === runId)),
...failedRunIds,
]),
];
} catch { this.completedRuns = []; this.expandedRunIds = []; this.runHistoryHasMore = false; this.runHistoryPage = 1; }
}
toggleRunDetails(runId: string): void {
@@ -185,6 +206,14 @@ export class TranslationJobModel {
}
}
/** Dismiss the completed run progress panel and reset store */
handleDismissRun(): void {
resetTranslationRun();
this.isRunning = false;
this.runComplete = false;
this.runError = '';
}
// ── Actions: Data loading ────────────────────────────────────
async loadInitialData(): Promise<void> {

View File

@@ -269,16 +269,17 @@
runComplete={m.runComplete}
completedRuns={m.completedRuns}
expandedRunIds={m.expandedRunIds}
runHistoryHasMore={m.runHistoryHasMore}
bind:showPageBulkReplace={m.showPageBulkReplace}
schemaValidated={m.schemaValidated}
onTriggerRun={(full) => m.handleTriggerRun(full)}
onRetryRun={() => m.handleRetryRun()}
onRetryInsert={() => m.handleRetryInsert()}
onLoadRunHistory={() => m.loadRunHistory()}
onLoadMoreRuns={() => m.loadRunHistory(true)}
onToggleRunDetails={(id) => m.toggleRunDetails(id)}
onBulkReplaceApplied={() => { m.showPageBulkReplace = false; m.loadRunHistory(); }}
onBulkReplaceClose={() => m.showPageBulkReplace = false}
onGoToTargetTab={() => m.activeTab = 'target'}
onDismissRun={() => m.handleDismissRun()}
/>
{:else}
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center">