- Add backend plugin modules: preview, executor, orchestrator, events, sql_generator, superset_executor, dictionary, scheduler, metrics - Add API routes for translate jobs, runs, dictionaries, preview, schedule, metrics, corrections - Add ORM models (12 tables) and Pydantic schemas (15 DTOs) - Register translate router in app.py with RBAC permission guards - Add frontend pages: job list, job config, dictionary list/editor, history - Add 7 reusable Svelte 5 components: Preview, RunProgress, RunResult, TermCorrection, BulkCorrection, ScheduleConfig, MetricsDashboard - Add searchable datasource dropdown with Superset API integration - Add i18n (en/ru) with ~300 keys across common, config, jobs, dictionaries, preview namespaces - Add Translation sidebar category with Jobs/Dictionaries/History sub-items - Hide health monitor error toast via suppressToast API option - Add 69 backend tests and 44 frontend test files - Fix: SupersetClient env resolution (string -> Environment object) - Fix: Dataset detail API returns proper database dict - Fix: Database dialect extraction fallback when metadata incomplete
293 lines
12 KiB
Svelte
293 lines
12 KiB
Svelte
<!-- [DEF:BulkCorrectionSidebar:Component] -->
|
|
<script>
|
|
/**
|
|
* @COMPLEXITY: 4
|
|
* @PURPOSE: Sidebar for collecting multiple incorrect terms across different rows,
|
|
* per-term correction inputs, submit atomically to dictionary.
|
|
* @LAYER: UI
|
|
* @RELATION: DEPENDS_ON -> [TranslateApi]
|
|
* @RELATION: DEPENDS_ON -> [dictionaryApi]
|
|
*
|
|
* @UX_STATE: closed -> Sidebar hidden
|
|
* @UX_STATE: collecting -> Items being collected, user can edit corrections
|
|
* @UX_STATE: reviewing -> User reviews collected corrections before submit
|
|
* @UX_STATE: submitting -> API call in progress
|
|
* @UX_STATE: submitted -> Success feedback
|
|
*
|
|
* @UX_FEEDBACK: Toast on success/error; count badge on toggle button
|
|
* @UX_RECOVERY: Remove individual items; retry submission
|
|
*/
|
|
import { t } from '$lib/i18n';
|
|
import { addToast } from '$lib/toasts.js';
|
|
import { submitBulkCorrections, dictionaryApi } from '$lib/api/translate.js';
|
|
|
|
let {
|
|
runId = '',
|
|
/** @type {Array<{sourceTerm: string, incorrectTarget: string, rowKey?: string}>} */
|
|
initialItems = [],
|
|
onClose = () => {},
|
|
onSubmitted = () => {}
|
|
} = $props();
|
|
|
|
/** @type {'closed'|'collecting'|'reviewing'|'submitting'|'submitted'} */
|
|
let uxState = $state('closed');
|
|
let items = $state([]);
|
|
let dictionaries = $state([]);
|
|
let selectedDictId = $state('');
|
|
let submitResult = $state(null);
|
|
let isOpen = $state(false);
|
|
|
|
// Open sidebar when items are provided
|
|
$effect(() => {
|
|
if (initialItems.length > 0) {
|
|
// Merge new items into existing collection, deduplicate by sourceTerm+incorrectTarget
|
|
for (const newItem of initialItems) {
|
|
const exists = items.some(
|
|
i => i.sourceTerm === newItem.sourceTerm && i.incorrectTarget === newItem.incorrectTarget
|
|
);
|
|
if (!exists) {
|
|
items = [...items, { ...newItem, correctedTarget: '' }];
|
|
}
|
|
}
|
|
if (!isOpen) {
|
|
openSidebar();
|
|
}
|
|
}
|
|
});
|
|
|
|
async function openSidebar() {
|
|
if (items.length === 0) return;
|
|
isOpen = true;
|
|
uxState = 'collecting';
|
|
await loadDictionaries();
|
|
}
|
|
|
|
function closeSidebar() {
|
|
isOpen = false;
|
|
uxState = 'closed';
|
|
onClose();
|
|
}
|
|
|
|
async function loadDictionaries() {
|
|
try {
|
|
const result = await dictionaryApi.fetchDictionaries({ page_size: 100 });
|
|
dictionaries = Array.isArray(result) ? result : (result?.items || []);
|
|
if (dictionaries.length === 1) {
|
|
selectedDictId = dictionaries[0].id;
|
|
}
|
|
} catch (err) {
|
|
addToast($t.translate?.common?.error + ': ' + ($t.translate?.corrections?.loading || 'Failed to load dictionaries'), 'error');
|
|
}
|
|
}
|
|
|
|
function updateCorrection(index, value) {
|
|
items = items.map((item, i) => i === index ? { ...item, correctedTarget: value } : item);
|
|
}
|
|
|
|
function removeItem(index) {
|
|
items = items.filter((_, i) => i !== index);
|
|
if (items.length === 0) {
|
|
closeSidebar();
|
|
}
|
|
}
|
|
|
|
async function handleSubmitAll() {
|
|
if (!selectedDictId || items.length === 0) return;
|
|
|
|
// Validate all items have corrected targets
|
|
const invalidItems = items.filter(i => !i.correctedTarget.trim());
|
|
if (invalidItems.length > 0) {
|
|
addToast($t.translate?.corrections?.submit_failed || 'All items must have a corrected target', 'error');
|
|
return;
|
|
}
|
|
|
|
uxState = 'submitting';
|
|
try {
|
|
const corrections = items.map(item => ({
|
|
source_term: item.sourceTerm,
|
|
incorrect_target_term: item.incorrectTarget,
|
|
corrected_target_term: item.correctedTarget.trim(),
|
|
origin_run_id: runId || undefined,
|
|
origin_row_key: item.rowKey || undefined,
|
|
}));
|
|
|
|
const result = await submitBulkCorrections({
|
|
corrections,
|
|
dictionary_id: selectedDictId,
|
|
});
|
|
|
|
submitResult = result;
|
|
uxState = 'submitted';
|
|
|
|
const successCount = result.created_count || result.updated_count || corrections.length;
|
|
const totalCount = corrections.length;
|
|
if (successCount === totalCount) {
|
|
addToast($t.translate?.corrections?.submit_success || 'All corrections submitted', 'success');
|
|
} else {
|
|
addToast(
|
|
($t.translate?.corrections?.submit_partial || '{success} of {total} submitted')
|
|
.replace('{success}', successCount)
|
|
.replace('{total}', totalCount),
|
|
'info'
|
|
);
|
|
}
|
|
onSubmitted(result);
|
|
} catch (err) {
|
|
addToast(err?.message || $t.translate?.corrections?.submit_failed || 'Failed to submit corrections', 'error');
|
|
uxState = 'collecting';
|
|
}
|
|
}
|
|
|
|
function resetAndClose() {
|
|
items = [];
|
|
submitResult = null;
|
|
closeSidebar();
|
|
}
|
|
|
|
// Derived
|
|
let validItemCount = $derived(items.filter(i => i.correctedTarget.trim()).length);
|
|
</script>
|
|
|
|
<!-- Toggle button -->
|
|
<button
|
|
onclick={openSidebar}
|
|
class="fixed right-0 top-1/3 z-40 flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-l-lg shadow-lg hover:bg-blue-700 transition-all translate-x-0"
|
|
class:translate-x-[360px]={isOpen}
|
|
title={$t.translate?.corrections?.title}
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
</svg>
|
|
<span class="text-xs font-medium">{$t.translate?.corrections?.title}</span>
|
|
{#if items.length > 0}
|
|
<span class="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
|
|
{items.length}
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Sidebar overlay -->
|
|
{#if isOpen}
|
|
<div class="fixed inset-0 z-30 bg-black/20" onclick={closeSidebar}></div>
|
|
|
|
<div class="fixed right-0 top-0 z-40 h-full w-[360px] bg-white shadow-2xl border-l border-gray-200 flex flex-col">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-gray-900">{$t.translate?.corrections?.title}</h3>
|
|
<p class="text-xs text-gray-500">{$t.translate?.corrections?.subtitle}</p>
|
|
</div>
|
|
<button onclick={closeSidebar} class="p-1 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div class="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
|
{#if uxState === 'collecting'}
|
|
{#if items.length === 0}
|
|
<div class="text-center py-8">
|
|
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
</svg>
|
|
<p class="text-sm text-gray-500">{$t.translate?.corrections?.no_items}</p>
|
|
<p class="text-xs text-gray-400 mt-1">{$t.translate?.corrections?.add_hint}</p>
|
|
</div>
|
|
{:else}
|
|
{#each items as item, idx}
|
|
<div class="border border-gray-200 rounded-lg p-3 space-y-2">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-xs font-medium text-gray-500">{$t.translate?.corrections?.source_term}</p>
|
|
<p class="text-sm font-mono text-gray-800 truncate">{item.sourceTerm}</p>
|
|
</div>
|
|
<button
|
|
onclick={() => removeItem(idx)}
|
|
class="ml-2 p-0.5 text-gray-400 hover:text-red-500 rounded"
|
|
title={$t.translate?.corrections?.remove}
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-medium text-red-500">{$t.translate?.corrections?.incorrect_target}</p>
|
|
<p class="text-sm font-mono text-red-600 truncate">{item.incorrectTarget}</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs font-medium text-green-600">{$t.translate?.corrections?.corrected_target}</label>
|
|
<input
|
|
type="text"
|
|
value={item.correctedTarget}
|
|
oninput={(e) => updateCorrection(idx, e.target.value)}
|
|
placeholder={$t.translate?.corrections?.corrected_placeholder}
|
|
class="w-full mt-1 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
|
|
{:else if uxState === 'submitting'}
|
|
<div class="flex flex-col items-center justify-center py-12">
|
|
<div class="animate-spin w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full mb-3"></div>
|
|
<p class="text-sm text-gray-500">{$t.translate?.corrections?.submitting}</p>
|
|
</div>
|
|
|
|
{:else if uxState === 'submitted'}
|
|
<div class="text-center py-8">
|
|
<svg class="w-12 h-12 text-green-500 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
<p class="text-sm font-medium text-gray-900">{$t.translate?.corrections?.submit_success}</p>
|
|
{#if submitResult}
|
|
<p class="text-xs text-gray-500 mt-1">
|
|
{$t.translate?.corrections?.submit_partial
|
|
?.replace('{success}', submitResult.created_count || submitResult.updated_count || items.length)
|
|
?.replace('{total}', items.length)}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="border-t border-gray-200 px-4 py-3 space-y-3">
|
|
{#if uxState === 'collecting' && items.length > 0}
|
|
<!-- Dictionary selector -->
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">{$t.translate?.corrections?.dictionary}</label>
|
|
<select
|
|
bind:value={selectedDictId}
|
|
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm"
|
|
>
|
|
<option value="">{$t.translate?.corrections?.select_dictionary}</option>
|
|
{#each dictionaries as dict}
|
|
<option value={dict.id}>{dict.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<button
|
|
onclick={handleSubmitAll}
|
|
disabled={!selectedDictId || validItemCount === 0}
|
|
class="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{$t.translate?.corrections?.submit_all?.replace('{count}', validItemCount)}
|
|
</button>
|
|
{:else if uxState === 'submitted'}
|
|
<button
|
|
onclick={resetAndClose}
|
|
class="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
{$t.translate?.common?.close}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<!-- [/DEF:BulkCorrectionSidebar:Component] -->
|