Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-04-16 17:46:39 +03:00
parent 307282928c
commit f9728b8a4c
13 changed files with 906 additions and 109 deletions

View File

@@ -1,69 +1,76 @@
<template>
<q-page v-if="canUpdateLanguage" class="q-pa-md">
<div class="row q-col-gutter-sm items-end q-mb-md">
<div class="col-12 col-md-4">
<q-input
v-model="filters.q"
dense
outlined
clearable
label="Kelime ara"
<template>
<q-page v-if="canUpdateLanguage" class="q-pa-md translation-page">
<div class="translation-toolbar sticky-toolbar">
<div class="row q-col-gutter-sm items-end q-mb-md">
<div class="col-12 col-md-4">
<q-input
v-model="filters.q"
dense
outlined
clearable
label="Kelime ara"
/>
</div>
<div class="col-auto">
<q-btn color="primary" icon="search" label="Getir" @click="loadRows" />
</div>
<div class="col-auto">
<q-btn
color="secondary"
icon="sync"
label="YENİ KELİMELERİ GETİR"
:loading="store.saving"
@click="syncSources"
/>
</div>
<div class="col-auto">
<q-toggle v-model="autoTranslate" dense color="primary" label="Oto Çeviri" />
</div>
</div>
<div class="row q-gutter-sm q-mb-sm">
<q-btn
color="accent"
icon="g_translate"
label="Seçilenleri Çevir"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="translateSelectedRows"
/>
</div>
<div class="col-auto">
<q-btn color="primary" icon="search" label="Getir" @click="loadRows" />
</div>
<div class="col-auto">
<q-btn
color="secondary"
icon="sync"
label="YENİ KELİMELERİ GETİR"
icon="done_all"
label="Seçilenleri Onayla"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="syncSources"
@click="bulkApproveSelected"
/>
<q-btn
color="primary"
icon="save"
label="Seçilenleri Toplu Güncelle"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="bulkSaveSelected"
/>
</div>
<div class="col-auto">
<q-toggle v-model="autoTranslate" dense color="primary" label="Oto Çeviri" />
</div>
</div>
<div class="row q-gutter-sm q-mb-sm">
<q-btn
color="accent"
icon="g_translate"
label="Seçilenleri Çevir"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="translateSelectedRows"
/>
<q-btn
color="secondary"
icon="done_all"
label="Seçilenleri Onayla"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="bulkApproveSelected"
/>
<q-btn
color="primary"
icon="save"
label="Seçilenleri Toplu Güncelle"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="bulkSaveSelected"
/>
</div>
<q-table
ref="tableRef"
class="translation-table"
flat
bordered
dense
virtual-scroll
:virtual-scroll-sticky-size-start="56"
row-key="t_key"
:loading="store.loading || store.saving"
:rows="pivotRows"
:columns="columns"
:rows-per-page-options="[0]"
:pagination="{ rowsPerPage: 0 }"
v-model:pagination="tablePagination"
hide-bottom
@virtual-scroll="onVirtualScroll"
>
<template #body-cell-actions="props">
<q-td :props="props">
@@ -91,7 +98,9 @@
<template #body-cell-source_text_tr="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'source_text_tr')">
<q-input v-model="rowDraft(props.row.t_key).source_text_tr" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<div class="source-text-label" :title="rowDraft(props.row.t_key).source_text_tr">
{{ rowDraft(props.row.t_key).source_text_tr }}
</div>
</q-td>
</template>
@@ -111,37 +120,79 @@
<template #body-cell-en="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'en')">
<q-input v-model="rowDraft(props.row.t_key).en" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).en"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
<template #body-cell-de="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'de')">
<q-input v-model="rowDraft(props.row.t_key).de" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).de"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
<template #body-cell-es="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'es')">
<q-input v-model="rowDraft(props.row.t_key).es" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).es"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
<template #body-cell-it="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'it')">
<q-input v-model="rowDraft(props.row.t_key).it" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).it"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
<template #body-cell-ru="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'ru')">
<q-input v-model="rowDraft(props.row.t_key).ru" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).ru"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
<template #body-cell-ar="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'ar')">
<q-input v-model="rowDraft(props.row.t_key).ar" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).ar"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
</q-table>
@@ -155,7 +206,7 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { usePermission } from 'src/composables/usePermission'
import { useTranslationStore } from 'src/stores/translationStore'
@@ -169,6 +220,18 @@ const filters = ref({
q: ''
})
const autoTranslate = ref(false)
const tableRef = ref(null)
const FETCH_LIMIT = 1400
const loadedOffset = ref(0)
const hasMoreRows = ref(true)
const loadingMore = ref(false)
const tablePagination = ref({
page: 1,
rowsPerPage: 0,
sortBy: 'source_text_tr',
descending: false
})
let filterReloadTimer = null
const sourceTypeOptions = [
{ label: 'dummy', value: 'dummy' },
@@ -179,15 +242,14 @@ const sourceTypeOptions = [
const columns = [
{ name: 'actions', label: 'Güncelle', field: 'actions', align: 'left' },
{ name: 'select', label: 'Seç', field: 'select', align: 'left' },
{ name: 't_key', label: 'Key', field: 't_key', align: 'left', sortable: true },
{ name: 'source_text_tr', label: 'Türkçe kaynak', field: 'source_text_tr', align: 'left' },
{ name: 'source_type', label: 'Veri tipi', field: 'source_type', align: 'left' },
{ name: 'en', label: 'English', field: 'en', align: 'left' },
{ name: 'de', label: 'Deutch', field: 'de', align: 'left' },
{ name: 'es', label: 'Espanol', field: 'es', align: 'left' },
{ name: 'it', label: 'Italiano', field: 'it', align: 'left' },
{ name: 'ru', label: 'Русский', field: 'ru', align: 'left' },
{ name: 'ar', label: 'العربية', field: 'ar', align: 'left' }
{ name: 'source_text_tr', label: 'Türkçe Metin', field: 'source_text_tr', align: 'left', style: 'min-width: 340px' },
{ name: 'source_type', label: 'Kaynak', field: 'source_type', align: 'left', style: 'min-width: 140px' },
{ name: 'en', label: 'İngilizce', field: 'en', align: 'left', style: 'min-width: 220px' },
{ name: 'de', label: 'Almanca', field: 'de', align: 'left', style: 'min-width: 220px' },
{ name: 'es', label: 'İspanyolca', field: 'es', align: 'left', style: 'min-width: 220px' },
{ name: 'it', label: 'İtalyanca', field: 'it', align: 'left', style: 'min-width: 220px' },
{ name: 'ru', label: 'Rusça', field: 'ru', align: 'left', style: 'min-width: 220px' },
{ name: 'ar', label: 'Arapça', field: 'ar', align: 'left', style: 'min-width: 220px' }
]
const draftByKey = ref({})
@@ -242,10 +304,33 @@ const pivotRows = computed(() => {
return Array.from(byKey.values()).sort((a, b) => a.t_key.localeCompare(b.t_key))
})
function snapshotDrafts () {
function snapshotDrafts (options = {}) {
const preserveDirty = Boolean(options?.preserveDirty)
const draft = {}
const original = {}
for (const row of pivotRows.value) {
const existingDraft = draftByKey.value[row.t_key]
const existingOriginal = originalByKey.value[row.t_key]
const keepExisting = preserveDirty &&
existingDraft &&
existingOriginal &&
(
existingDraft.source_text_tr !== existingOriginal.source_text_tr ||
existingDraft.source_type !== existingOriginal.source_type ||
existingDraft.en !== existingOriginal.en ||
existingDraft.de !== existingOriginal.de ||
existingDraft.es !== existingOriginal.es ||
existingDraft.it !== existingOriginal.it ||
existingDraft.ru !== existingOriginal.ru ||
existingDraft.ar !== existingOriginal.ar
)
if (keepExisting) {
draft[row.t_key] = { ...existingDraft }
original[row.t_key] = { ...existingOriginal }
continue
}
draft[row.t_key] = {
source_text_tr: row.source_text_tr || '',
source_type: row.source_type || 'dummy',
@@ -280,8 +365,9 @@ function rowDraft (key) {
}
function buildFilters () {
const query = String(filters.value.q || '').trim()
return {
q: filters.value.q || undefined
q: query || undefined
}
}
@@ -349,10 +435,30 @@ function queueAutoSave (key) {
autoSaveTimers.set(key, timer)
}
async function fetchRowsChunk (append = false) {
const params = {
...buildFilters(),
limit: FETCH_LIMIT,
offset: append ? loadedOffset.value : 0
}
await store.fetchRows(params, { append })
const incomingCount = Number(store.count) || 0
if (append) {
loadedOffset.value += incomingCount
} else {
loadedOffset.value = incomingCount
}
hasMoreRows.value = incomingCount === FETCH_LIMIT
snapshotDrafts({ preserveDirty: append })
}
async function loadRows () {
try {
await store.fetchRows(buildFilters())
snapshotDrafts()
loadedOffset.value = 0
hasMoreRows.value = true
await fetchRowsChunk(false)
} catch (err) {
console.error('[translation-sync][ui] loadRows:error', {
message: err?.message || 'Çeviri satırları yüklenemedi'
@@ -364,6 +470,42 @@ async function loadRows () {
}
}
async function loadMoreRows () {
if (!hasMoreRows.value || loadingMore.value || store.loading || store.saving) return
loadingMore.value = true
try {
await fetchRowsChunk(true)
} finally {
loadingMore.value = false
}
}
async function ensureEnoughVisibleRows (minRows = 120, maxBatches = 4) {
let guard = 0
while (hasMoreRows.value && pivotRows.value.length < minRows && guard < maxBatches) {
await loadMoreRows()
guard++
}
}
function onVirtualScroll (details) {
const to = Number(details?.to || 0)
if (!Number.isFinite(to)) return
if (to >= pivotRows.value.length - 15) {
void loadMoreRows()
}
}
function scheduleFilterReload () {
if (filterReloadTimer) {
clearTimeout(filterReloadTimer)
}
filterReloadTimer = setTimeout(() => {
filterReloadTimer = null
void loadRows()
}, 350)
}
async function ensureMissingLangRows (key, draft, langs) {
const missingLangs = []
if (!langs.en && String(draft.en || '').trim() !== '') missingLangs.push('en')
@@ -618,11 +760,81 @@ async function syncSources () {
}
onMounted(() => {
loadRows()
void loadRows()
})
onBeforeUnmount(() => {
if (filterReloadTimer) {
clearTimeout(filterReloadTimer)
filterReloadTimer = null
}
})
watch(
() => filters.value.q,
() => { scheduleFilterReload() }
)
watch(
[() => tablePagination.value.sortBy, () => tablePagination.value.descending],
() => { void ensureEnoughVisibleRows(120, 4) }
)
</script>
<style scoped>
.translation-page {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.translation-toolbar {
background: #fff;
padding-top: 6px;
}
.sticky-toolbar {
position: sticky;
top: 0;
z-index: 35;
}
.translation-table {
flex: 1;
min-height: 0;
}
.translation-table :deep(.q-table__middle) {
max-height: calc(100vh - 280px);
overflow: auto;
}
.translation-table :deep(.q-table thead tr th) {
position: sticky;
top: 0;
z-index: 30;
background: #fff;
}
.translation-table :deep(.q-table tbody td) {
vertical-align: top;
padding: 6px;
}
.translation-table :deep(.q-field__native) {
line-height: 1.35;
word-break: break-word;
}
.source-text-label {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
max-height: 11.2em;
overflow: auto;
}
.cell-dirty {
background: #fff3cd;
}
@@ -631,3 +843,4 @@ onMounted(() => {
background: #d9f7e8;
}
</style>