303 lines
14 KiB
Svelte
303 lines
14 KiB
Svelte
<!-- #region TargetSchemaHint [C:3] [TYPE Component] [SEMANTICS translate, schema, validation, hint, button] -->
|
||
<!-- @BRIEF Подсказка о колонках целевой таблицы + кнопка "Проверить схему".
|
||
Пользователь сам нажимает кнопку — никакого авто-шума.
|
||
Показывает diff ожидаемых vs актуальных колонок. -->
|
||
<!-- @LAYER UI -->
|
||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:checkTargetTableSchema] -->
|
||
<!-- @RELATION BINDS_TO -> [EXT:frontend:api] -->
|
||
<!-- @UX_STATE Idle -> Кнопка активна. Ждём нажатия. -->
|
||
<!-- @UX_STATE Checking -> Спиннер, кнопка disabled. -->
|
||
<!-- @UX_STATE Complete -> Показан результат: все ок / отсутствуют колонки / таблица не найдена. -->
|
||
<!-- @UX_STATE Error -> Ошибка соединения. Кнопка активна для повтора. -->
|
||
<!-- @UX_FEEDBACK Цветовой индикатор: зелёный (все ок), жёлтый (не все колонки), красный (таблица не найдена/ошибка). -->
|
||
<!-- @UX_REACTIVITY Props -> $props() для полей конфигурации. -->
|
||
<script>
|
||
import { onDestroy } from 'svelte';
|
||
import { checkTargetTableSchema } from '$lib/api/translate.js';
|
||
import { _ } from '$lib/i18n';
|
||
|
||
let {
|
||
environmentId = '',
|
||
targetDatabaseId = '',
|
||
targetSchema = '',
|
||
targetTable = '',
|
||
targetKeyCols = [],
|
||
targetColumn = null,
|
||
translationColumn = null,
|
||
targetLanguageColumn = null,
|
||
targetSourceColumn = null,
|
||
targetSourceLanguageColumn = null,
|
||
} = $props();
|
||
|
||
/** @type {'idle'|'checking'|'complete'|'error'} */
|
||
let state = $state('idle');
|
||
let result = $state(null);
|
||
let abortController = $state(null);
|
||
let renderKey = $state(0);
|
||
|
||
/** CSS-класс body в зависимости от состояния */
|
||
let bodyClass = $derived.by(() => {
|
||
if (state === 'complete' && result?.all_present) return 'px-3 py-2.5 bg-green-50';
|
||
if (state === 'complete' && !result?.all_present && result?.table_exists) return 'px-3 py-2.5 bg-yellow-50';
|
||
if (state === 'error' || (state === 'complete' && !result?.table_exists)) return 'px-3 py-2.5 bg-red-50';
|
||
return 'px-3 py-2.5';
|
||
});
|
||
|
||
/** Колонки с типами для отображения — $state, наполняется в handleCheck() */
|
||
let actualColumns = $state([]);
|
||
|
||
/** Есть ли минимальные данные для проверки? */
|
||
let canCheck = $derived(
|
||
!!environmentId
|
||
&& !!targetDatabaseId
|
||
&& !!targetTable
|
||
);
|
||
|
||
/** Текст кнопки */
|
||
let buttonLabel = $derived.by(() => {
|
||
if (state === 'checking') return _('translate.target_schema.checking') || 'Checking…';
|
||
if (state === 'complete') return _('translate.target_schema.check_again') || 'Check Again';
|
||
return _('translate.target_schema.check_button') || 'Check Target Schema';
|
||
});
|
||
|
||
/**
|
||
* Выводит тип колонки по соглашению именования,
|
||
* когда реальный тип из БД недоступен.
|
||
*/
|
||
function inferType(colName) {
|
||
const n = colName.toLowerCase();
|
||
// Служебные флаги и boolean
|
||
if (n === 'is_original' || n.startsWith('is_') || n.startsWith('has_') || n.endsWith('_flag')) return 'INT';
|
||
// Внешние ключи
|
||
if (n.endsWith('_id') || n.endsWith('_key')) return 'INT';
|
||
// Код языка
|
||
if (n.endsWith('_lang') || n === 'lang_code' || n.endsWith('_language')) return 'STRING';
|
||
// Даты
|
||
if (n.endsWith('_date') || n.endsWith('_time') || n.endsWith('_at')) return 'DATETIME';
|
||
// Всё остальное — текст
|
||
return 'STRING';
|
||
}
|
||
|
||
/** Основная проверка */
|
||
async function handleCheck() {
|
||
if (!canCheck || state === 'checking') return;
|
||
|
||
// Abort предыдущего запроса если был
|
||
if (abortController) abortController.abort();
|
||
abortController = new AbortController();
|
||
const signal = abortController.signal;
|
||
|
||
state = 'checking';
|
||
result = null;
|
||
try {
|
||
const res = await checkTargetTableSchema({
|
||
environment_id: environmentId,
|
||
target_database_id: targetDatabaseId,
|
||
target_schema: targetSchema || 'public',
|
||
target_table: targetTable,
|
||
target_key_cols: targetKeyCols || [],
|
||
target_column: targetColumn || null,
|
||
translation_column: translationColumn || null,
|
||
target_language_column: targetLanguageColumn || null,
|
||
target_source_column: targetSourceColumn || null,
|
||
target_source_language_column: targetSourceLanguageColumn || null,
|
||
});
|
||
// Если запрос был отменён — не обновляем state
|
||
if (signal.aborted) return;
|
||
result = res;
|
||
// Build merged column list with types from actual_columns
|
||
const ac = res?.actual_columns || [];
|
||
const actualMap = {};
|
||
for (const c of ac) actualMap[c.name] = c;
|
||
actualColumns = (res?.expected_columns || []).map(col => {
|
||
const match = actualMap[col.name];
|
||
return {
|
||
name: col.name,
|
||
data_type: match ? match.data_type : inferType(col.name),
|
||
is_missing: (res?.missing_columns || []).some(m => m.name === col.name),
|
||
};
|
||
});
|
||
state = 'complete';
|
||
renderKey++;
|
||
} catch (err) {
|
||
if (signal.aborted) return;
|
||
state = 'error';
|
||
result = null;
|
||
}
|
||
}
|
||
|
||
onDestroy(() => {
|
||
if (abortController) abortController.abort();
|
||
});
|
||
</script>
|
||
|
||
<div class="target-schema-hint mt-3 rounded-lg border text-sm transition-colors"
|
||
class:border-green-300={state === 'complete' && result?.all_present}
|
||
class:border-yellow-300={state === 'complete' && !result?.all_present && result?.table_exists}
|
||
class:border-red-300={state === 'error' || (state === 'complete' && !result?.table_exists)}
|
||
class:border-gray-200={state === 'idle' || state === 'checking'}
|
||
role="region"
|
||
aria-label={_('translate.target_schema.region_label') || 'Target Schema Check'}
|
||
>
|
||
<!-- Header: label + кнопка -->
|
||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100"
|
||
class:bg-green-50={state === 'complete' && result?.all_present}
|
||
class:bg-yellow-50={state === 'complete' && !result?.all_present && result?.table_exists}
|
||
class:bg-red-50={state === 'error' || (state === 'complete' && !result?.table_exists)}
|
||
class:bg-gray-50={state === 'idle' || state === 'checking'}
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<!-- Иконка состояния -->
|
||
{#if state === 'checking'}
|
||
<svg class="w-4 h-4 text-gray-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||
</svg>
|
||
{:else if state === 'complete' && result?.all_present}
|
||
<svg class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||
</svg>
|
||
{:else if state === 'complete' && !result?.table_exists}
|
||
<svg class="w-4 h-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
|
||
</svg>
|
||
{:else if state === 'complete' && !result?.all_present}
|
||
<svg class="w-4 h-4 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
{:else}
|
||
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||
</svg>
|
||
{/if}
|
||
|
||
<span class="font-medium text-gray-700 text-xs uppercase tracking-wide">
|
||
{_('translate.target_schema.title') || 'Target Schema'}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Кнопка -->
|
||
<button
|
||
onclick={handleCheck}
|
||
disabled={!canCheck || state === 'checking'}
|
||
class="text-xs font-medium px-3 py-1.5 rounded-md border transition-colors focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:outline-none"
|
||
class:bg-white={state !== 'checking'}
|
||
class:text-gray-700={state !== 'checking'}
|
||
class:border-gray-300={state !== 'checking'}
|
||
class:hover:bg-gray-50={state !== 'checking'}
|
||
class:bg-gray-100={state === 'checking' || !canCheck}
|
||
class:text-gray-400={state === 'checking' || !canCheck}
|
||
class:border-gray-200={state === 'checking' || !canCheck}
|
||
class:cursor-not-allowed={!canCheck || state === 'checking'}
|
||
aria-busy={state === 'checking'}
|
||
>
|
||
{buttonLabel}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Body: результат проверки -->
|
||
<div class={bodyClass}>
|
||
{#if state === 'idle'}
|
||
<p class="text-gray-500">
|
||
{#if !targetTable}
|
||
{_('translate.target_schema.enter_table') || 'Enter a target table name and click "Check Target Schema".'}
|
||
{:else if !targetDatabaseId}
|
||
{_('translate.target_schema.select_database') || 'Select a target database and click "Check Target Schema".'}
|
||
{:else}
|
||
{_('translate.target_schema.press_button') || 'Click "Check Target Schema" to verify the target table columns.'}
|
||
{/if}
|
||
</p>
|
||
|
||
{:else if state === 'checking'}
|
||
<p class="text-gray-500">
|
||
{_('translate.target_schema.checking') || 'Checking target table schema…'}
|
||
</p>
|
||
|
||
{:else if state === 'complete' && result}
|
||
{#key renderKey}
|
||
{#if !result.table_exists}
|
||
<div class="flex items-center gap-2 text-red-700">
|
||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
|
||
</svg>
|
||
<p class="font-medium">{_('translate.target_schema.table_not_found') || 'Target table not found in the database.'}</p>
|
||
</div>
|
||
|
||
{:else if result.all_present}
|
||
<div class="flex items-center gap-2 text-green-700">
|
||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||
</svg>
|
||
<p class="font-medium">{_('translate.target_schema.all_present') || 'Target table has all required columns.'}</p>
|
||
</div>
|
||
|
||
{:else}
|
||
<div class="flex items-center gap-2 text-yellow-700 mb-2">
|
||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<p class="font-medium">{_('translate.target_schema.missing_columns_title') || 'Target table is missing some required columns.'}</p>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Список колонок с типами -->
|
||
{#if actualColumns.length > 0}
|
||
<div class="mt-1.5">
|
||
<p class="text-xs font-medium text-gray-500 mb-1.5">
|
||
{_('translate.target_schema.expected_columns') || 'Required columns for translation inserts:'}
|
||
</p>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
{#each actualColumns as col}
|
||
<span
|
||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono border"
|
||
class:bg-green-100={!col.is_missing}
|
||
class:text-green-800={!col.is_missing}
|
||
class:border-green-300={!col.is_missing}
|
||
class:bg-red-100={col.is_missing}
|
||
class:text-red-800={col.is_missing}
|
||
class:border-red-300={col.is_missing}
|
||
>
|
||
{#if col.is_missing}
|
||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
{:else}
|
||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||
</svg>
|
||
{/if}
|
||
<span>{col.name}</span>
|
||
{#if col.data_type}<span class="text-[10px] text-green-600/70">({col.data_type})</span>{/if}
|
||
</span>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{/key}
|
||
|
||
<!-- Сопроводительный текст если есть пропущенные -->
|
||
{#if result.table_exists && !result.all_present}
|
||
<div class="mt-2 text-xs text-yellow-700">
|
||
<p>{_('translate.target_schema.add_columns') || 'Add these missing columns to the target table, or configure custom column names in "Target Column Mapping" above.'}</p>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Ошибка от Superset -->
|
||
{#if result.error}
|
||
<p class="mt-1.5 text-xs text-red-600 font-mono">{result.error}</p>
|
||
{/if}
|
||
|
||
{:else if state === 'error'}
|
||
<div class="flex items-center gap-2 text-red-700">
|
||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
|
||
</svg>
|
||
<p class="font-medium">
|
||
{_('translate.target_schema.check_error') || 'Could not verify target table schema. Make sure the table exists and is accessible.'}
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<!-- #endregion TargetSchemaHint -->
|