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:
@@ -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>
|
||||
Reference in New Issue
Block a user