Split import page into separate local and remote database workflows.

Route IndexedDB and server import/export through dedicated APIs and UI panels so each storage target can be managed independently of the active data mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gitrusprus
2026-07-06 15:43:09 +03:00
co-authored by Cursor
parent 986d36ff51
commit f5937040fa
6 changed files with 666 additions and 191 deletions
@@ -0,0 +1,58 @@
<template>
<div class="export-row">
<div class="form-group export-format-field">
<label :for="inputId">Формат файла</label>
<select :id="inputId" :value="modelValue" class="form-control" @change="onChange">
<option value="csv">CSV (.csv)</option>
<option value="json">JSON (.json)</option>
<option value="vcf">vCard (.vcf)</option>
</select>
</div>
<button
class="btn btn-primary"
type="button"
:disabled="disabled || exporting"
@click="$emit('export')"
>
{{ exporting ? 'Экспорт...' : buttonLabel }}
</button>
</div>
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
<span v-if="result.error">{{ result.error }}</span>
<span v-else>
Экспортировано контактов: <strong>{{ result.count }}</strong>
({{ result.formatLabel }}).
</span>
</div>
</template>
<script setup>
import { useId } from 'vue'
defineProps({
modelValue: { type: String, default: 'csv' },
exporting: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
result: { type: Object, default: null },
buttonLabel: { type: String, default: 'Экспортировать' },
})
const emit = defineEmits(['update:modelValue', 'export'])
const inputId = useId()
function onChange(event) {
emit('update:modelValue', event.target.value)
}
</script>
<style scoped>
.export-row {
display: flex;
align-items: flex-end;
gap: 12px;
}
.export-format-field {
margin-bottom: 0;
flex: 1;
}
</style>
@@ -0,0 +1,141 @@
<template>
<div class="import-file-block" :class="{ 'import-file-block--disabled': disabled }">
<h4 class="subsection-title">Импорт файла</h4>
<p class="text-muted import-hint">
CSV, JSON или vCard (.vcf).
</p>
<div
class="drop-zone"
:class="{ 'drag-over': isDragging, 'drop-zone--disabled': disabled }"
@dragover.prevent="onDragOver"
@dragleave="isDragging = false"
@drop.prevent="onDrop"
@click="openPicker"
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="drop-zone__icon">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div class="drop-zone__label">
{{ file?.name || 'Перетащите файл или нажмите для выбора' }}
</div>
<input ref="fileInput" type="file" accept=".csv,.json,.vcf,.vcard" style="display:none" @change="onFileSelect" />
</div>
<ImportResultAlert :result="result" />
<div class="import-file-actions">
<button
class="btn btn-primary"
type="button"
:disabled="disabled || !file || importing"
@click="$emit('import')"
>
<svg v-if="importing" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin-icon">
<path d="M21 12a9 9 0 1 1-6.22-8.56"/>
</svg>
{{ importing ? 'Импорт...' : importLabel }}
</button>
<button v-if="file" class="btn btn-secondary" type="button" :disabled="importing" @click="clear">
Сбросить
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ImportResultAlert from './ImportResultAlert.vue'
const props = defineProps({
file: { type: Object, default: null },
importing: { type: Boolean, default: false },
result: { type: Object, default: null },
disabled: { type: Boolean, default: false },
importLabel: { type: String, default: 'Импортировать' },
})
const emit = defineEmits(['update:file', 'import', 'reset'])
const fileInput = ref(null)
const isDragging = ref(false)
function openPicker() {
if (props.disabled) return
fileInput.value?.click()
}
function onDragOver() {
if (!props.disabled) isDragging.value = true
}
function onFileSelect(e) {
emit('update:file', e.target.files[0] || null)
}
function onDrop(e) {
isDragging.value = false
if (props.disabled) return
const picked = e.dataTransfer.files[0]
if (picked) {
emit('update:file', picked)
}
}
function clear() {
emit('update:file', null)
emit('reset')
if (fileInput.value) fileInput.value.value = ''
}
</script>
<style scoped>
.subsection-title {
font-size: 14px;
margin: 0 0 8px;
}
.import-hint {
font-size: 12px;
margin: 0 0 12px;
}
.drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 28px 16px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.drop-zone:hover,
.drag-over {
border-color: var(--accent);
background: var(--accent-dim);
}
.drop-zone--disabled {
opacity: 0.55;
cursor: not-allowed;
}
.drop-zone__icon {
opacity: 0.4;
margin: 0 auto 10px;
display: block;
}
.drop-zone__label {
font-size: 13px;
color: var(--text-muted);
}
.import-file-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.spin-icon {
animation: spin 0.7s linear infinite;
vertical-align: -2px;
margin-right: 4px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
@@ -0,0 +1,20 @@
<template>
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:16px;">
<span v-if="result.error">{{ result.error }}</span>
<span v-else>
В файле: <strong>{{ result.total ?? result.created + result.skipped }}</strong>,
импортировано: <strong>{{ result.created }}</strong> контактов<template v-if="result.importedRelations">, <strong>{{ result.importedRelations }}</strong> связей</template>,
пропущено: {{ result.skipped }}.
<span v-if="result.errors?.length"> Ошибок: {{ result.errors.length }}.</span>
</span>
</div>
<div v-if="result?.errors?.length" style="margin-top:8px;">
<div v-for="e in result.errors" :key="e" class="text-muted" style="font-size:12px;">{{ e }}</div>
</div>
</template>
<script setup>
defineProps({
result: { type: Object, default: null },
})
</script>