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
@@ -5,6 +5,9 @@ import { generateId } from '../../lib/uuid'
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
import { parseVcf } from '../../lib/import/vcard'
import { serializeContactsExport } from '../../lib/export/contacts'
import api from '../../api'
import { localContactRepository } from '../../infrastructure/repositories/contactRepository.local'
import { remoteContactRepository } from '../../infrastructure/repositories/contactRepository.remote'
function isLikelyEmail(value) {
return value.includes('@') && value.includes('.')
@@ -109,8 +112,107 @@ export async function importContactsFromFile(file) {
return { total: rows.length, created, skipped, errors }
}
async function importRowsWithCreator(rows, createFn) {
let created = 0
let skipped = 0
const errors = []
for (let i = 0; i < rows.length; i += 1) {
const payload = toContactPayload(rows[i])
if (!payload.name) {
skipped += 1
errors.push(`Запись ${i + 1}: отсутствует имя контакта`)
continue
}
await createFn(payload)
created += 1
}
return { total: rows.length, created, skipped, errors }
}
async function parseContactRowsFromFile(file, { allowLocalDump = false } = {}) {
if (!file) throw new Error('Файл не выбран')
const name = file.name.toLowerCase()
const rawText = await readText(file)
if (name.endsWith('.csv')) {
return { kind: 'rows', rows: parseCsv(rawText) }
}
if (name.endsWith('.json')) {
const raw = JSON.parse(rawText)
if (isLocalDataDump(raw) || isEncryptedLocalDump(raw)) {
if (!allowLocalDump) {
throw new Error('Полный бэкап приложения импортируйте в разделе «Локальная база».')
}
return { kind: 'dump', file }
}
return { kind: 'rows', rows: normalizeRows(raw) }
}
if (name.endsWith('.vcf') || name.endsWith('.vcard')) {
const rows = parseVcf(rawText)
if (!rows.length) {
throw new Error('В файле vCard не найдено контактов.')
}
return { kind: 'rows', rows }
}
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf) файлы.')
}
const ALLOWED_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf'])
function contactsExportResult(contacts, format) {
const normalized = String(format || 'csv').toLowerCase()
if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).')
}
const { filename, mime, content } = serializeContactsExport(contacts, normalized)
return {
filename,
blob: new Blob([content], { type: mime }),
count: contacts.length,
format: normalized,
}
}
export async function importContactsToLocalFromFile(file) {
const parsed = await parseContactRowsFromFile(file, { allowLocalDump: true })
if (parsed.kind === 'dump') {
const summary = await importLocalDump(parsed.file, '')
return {
total: summary.importedContacts + summary.importedRelations,
created: summary.importedContacts,
importedRelations: summary.importedRelations,
skipped: 0,
errors: [],
isDump: true,
}
}
return importRowsWithCreator(parsed.rows, (payload) => localContactRepository.create(payload))
}
export async function importContactsToRemoteFromFile(file) {
await parseContactRowsFromFile(file, { allowLocalDump: false })
const formData = new FormData()
formData.append('file', file)
const { data } = await api.post('/v1/import/', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000,
})
if (data?.error) throw new Error(data.error)
return data
}
export async function exportContactsFromLocal({ format = 'csv' } = {}) {
const contacts = await localContactRepository.list()
if (!contacts.length) throw new Error('Нет контактов в локальной базе.')
return contactsExportResult(contacts, format)
}
export async function exportContactsFromRemote({ format = 'csv' } = {}) {
const contacts = await remoteContactRepository.list()
if (!contacts.length) throw new Error('Нет контактов на сервере.')
return contactsExportResult(contacts, format)
}
export async function exportContacts({ format = 'csv' } = {}) {
const normalized = String(format || 'csv').toLowerCase()
if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
@@ -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>
+37
View File
@@ -19,10 +19,15 @@ import {
} from '../infrastructure/repositories/repositoryFactory'
import {
importContactsFromFile,
importContactsToLocalFromFile,
importContactsToRemoteFromFile,
exportContacts as exportContactsUseCase,
exportContactsFromLocal,
exportContactsFromRemote,
exportLocalData,
importLocalDump,
} from '../application/usecases/importExport'
import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
import { syncPendingChanges } from '../application/usecases/sync'
export const useContactsStore = defineStore('contacts', {
@@ -211,10 +216,42 @@ export const useContactsStore = defineStore('contacts', {
})
},
async importContactsToLocal(file) {
return this.withLoading('contactsLoading', async () => {
const data = await importContactsToLocalFromFile(file)
if (isLocalMode()) {
await this.fetchContacts()
if (data.isDump) await this.fetchRelations()
}
if (isLocalMode()) await syncPendingChanges()
this.bumpDataRevision()
return data
})
},
async importContactsToRemote(file) {
return this.withLoading('contactsLoading', async () => {
const data = await importContactsToRemoteFromFile(file)
if (isRemoteMode()) {
await this.fetchContacts()
}
this.bumpDataRevision()
return data
})
},
async exportContacts(format = 'csv') {
return exportContactsUseCase({ format })
},
async exportLocalContacts(format = 'csv') {
return exportContactsFromLocal({ format })
},
async exportRemoteContacts(format = 'csv') {
return exportContactsFromRemote({ format })
},
async exportData(passphrase = '') {
return exportLocalData({ passphrase })
},
+286 -169
View File
@@ -3,126 +3,70 @@
<div class="page-header">
<h2>Импорт и экспорт</h2>
</div>
<div class="page-content content-narrow">
<div class="card">
<h3 class="section-title">Загрузить файл</h3>
<div class="page-content">
<div class="card format-examples">
<h3 class="section-title">Поддерживаемые форматы</h3>
<p class="text-muted section-subtitle">
Поддерживаются форматы <strong style="color:var(--text)">CSV</strong>, <strong style="color:var(--text)">JSON</strong> и <strong style="color:var(--text)">vCard (.vcf)</strong>.
CSV, JSON и vCard (.vcf). Полный бэкап приложения (контакты, связи, карты) только JSON в разделе локальной базы.
</p>
<!-- Format examples -->
<div class="card" style="background:var(--surface-alt);margin-bottom:18px;padding:14px;">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px;">Пример CSV:</div>
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">name,email,phone,organization,position,notes
<pre class="format-pre">name,email,phone,organization,position,notes
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример JSON:</div>
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">[{"name":"Иван Иванов","email":"ivan@example.com","organization":"ООО Ромашка"}]</pre>
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример vCard (.vcf):</div>
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">BEGIN:VCARD
FN:Иван Иванов
EMAIL:ivan@example.com
TEL:+79000000001
ORG:ООО Ромашка
END:VCARD</pre>
</div>
<!-- Drop zone -->
<div
class="drop-zone"
:class="{ 'drag-over': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="onDrop"
@click="$refs.fileInput.click()"
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.4;margin:0 auto 10px;display:block;">
<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 style="font-size:13px;color:var(--text-muted);">
{{ selectedFile ? selectedFile.name : 'Перетащите файл или нажмите для выбора' }}
<div class="import-grid">
<!-- Local -->
<div class="card import-panel">
<div class="import-panel__head">
<h3 class="section-title">Локальная база</h3>
<span class="import-badge">IndexedDB</span>
</div>
<input ref="fileInput" type="file" accept=".csv,.json,.vcf,.vcard" style="display:none" @change="onFileSelect" />
</div>
<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>
<div style="margin-top:16px;">
<button
class="btn btn-primary"
:disabled="!selectedFile || importing"
@click="doImport"
>
<svg v-if="importing" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin .7s linear infinite;">
<path d="M21 12a9 9 0 1 1-6.22-8.56"/>
</svg>
{{ importing ? 'Импорт...' : 'Импортировать' }}
</button>
<button v-if="selectedFile" class="btn btn-secondary" style="margin-left:8px;" @click="reset">Сбросить</button>
</div>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border);" />
<h3 class="section-title">Экспорт контактов</h3>
<p class="text-muted section-subtitle">
Скачать все контакты в выбранном формате: <strong style="color:var(--text)">CSV</strong>,
<strong style="color:var(--text)">JSON</strong> или <strong style="color:var(--text)">vCard (.vcf)</strong>
совместимо с Nextcloud и другими адресными книгами.
Данные в браузере. Не зависит от выбранного режима в настройках.
<span v-if="isLocalMode" class="import-mode-hint">Сейчас активен локальный режим.</span>
</p>
<div class="export-row">
<div class="form-group" style="margin-bottom:0;flex:1;">
<label for="export-format">Формат файла</label>
<select id="export-format" v-model="exportFormat" class="form-control">
<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"
:disabled="exporting || store.totalContacts === 0"
@click="doExportContacts"
>
{{ exporting ? 'Экспорт...' : 'Экспортировать' }}
</button>
</div>
<p v-if="store.totalContacts === 0" class="text-muted" style="font-size:12px;margin-top:8px;">
Нет контактов для экспорта.
</p>
<div v-if="exportResult" class="alert" :class="exportResult.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
<span v-if="exportResult.error">{{ exportResult.error }}</span>
<span v-else>
Экспортировано контактов: <strong>{{ exportResult.count }}</strong>
({{ exportResult.formatLabel }}).
</span>
</div>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border);" />
<h3 class="section-title">Бэкап локальной базы</h3>
<ImportFileBlock
v-model:file="localFile"
:importing="localImporting"
:result="localResult"
import-label="Импортировать в локальную БД"
@import="doLocalImport"
@reset="resetLocal"
/>
<hr class="import-divider" />
<h4 class="subsection-title">Экспорт контактов</h4>
<ExportFormatRow
v-model="localExportFormat"
:exporting="localExporting"
:disabled="localContactCount === 0"
:result="localExportResult"
button-label="Экспортировать локально"
@export="doLocalExport"
/>
<p v-if="localContactCount === 0" class="text-muted import-empty-hint">
В локальной базе нет контактов.
</p>
<p v-else class="text-muted import-empty-hint">
Контактов в локальной базе: {{ localContactCount }}.
</p>
<hr class="import-divider" />
<h4 class="subsection-title">Полный бэкап</h4>
<p class="text-muted section-subtitle">
Экспортирует/импортирует локальные данные. Пароль для шифрования необязателен.
Экспорт или восстановление всей локальной базы (контакты, связи, карты). Пароль необязателен.
</p>
<div class="form-group">
<label>Пароль шифрования (необязательно)</label>
<input v-model="backupPassphrase" type="password" class="form-control" placeholder="Оставьте пустым для обычного JSON" />
</div>
<div>
<button class="btn btn-secondary" :disabled="busyBackup" @click="doExport">
{{ busyBackup ? 'Экспорт...' : 'Экспорт локальной БД' }}
<div class="import-actions">
<button class="btn btn-secondary" type="button" :disabled="busyBackup" @click="doExportBackup">
{{ busyBackup ? 'Экспорт...' : 'Экспорт бэкапа' }}
</button>
<button class="btn btn-secondary" style="margin-left:8px;" :disabled="busyBackup" @click="$refs.backupInput.click()">
<button class="btn btn-secondary" type="button" :disabled="busyBackup" @click="$refs.backupInput.click()">
Импорт бэкапа
</button>
<input
@@ -134,27 +78,97 @@ END:VCARD</pre>
/>
</div>
</div>
<!-- Remote -->
<div class="card import-panel">
<div class="import-panel__head">
<h3 class="section-title">Удалённая база</h3>
<span class="import-badge import-badge--remote">Сервер</span>
</div>
<p class="text-muted section-subtitle">
Django API. Нужен вход в аккаунт.
<span v-if="isRemoteMode" class="import-mode-hint">Сейчас активен серверный режим.</span>
</p>
<p v-if="serverProbeLoading" class="text-muted server-status">Проверка сервера</p>
<p v-else-if="serverProbe.ok" class="server-status server-status--ok">
Сервер доступен · контактов: {{ serverProbe.contactCount }} · связей: {{ serverProbe.relationCount }}
</p>
<p v-else class="alert alert-error server-status">{{ serverProbe.message }}</p>
<p v-if="!isAuthenticated" class="alert alert-error server-status">
Войдите в аккаунт для работы с серверной базой.
</p>
<ImportFileBlock
v-model:file="remoteFile"
:importing="remoteImporting"
:result="remoteResult"
:disabled="!canUseRemote"
import-label="Импортировать на сервер"
@import="doRemoteImport"
@reset="resetRemote"
/>
<hr class="import-divider" />
<h4 class="subsection-title">Экспорт контактов</h4>
<ExportFormatRow
v-model="remoteExportFormat"
:exporting="remoteExporting"
:disabled="!canUseRemote || serverProbe.contactCount === 0"
:result="remoteExportResult"
button-label="Экспортировать с сервера"
@export="doRemoteExport"
/>
<p v-if="canUseRemote && serverProbe.contactCount === 0" class="text-muted import-empty-hint">
На сервере нет контактов.
</p>
<p class="text-muted migration-link">
Перенос между локальной и серверной базой в
<RouterLink to="/settings">настройках</RouterLink>.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useContactsStore } from '../stores/contacts'
import { useNetworkMapsStore } from '../stores/networkMaps'
import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
import { getStoredAccessToken } from '../stores/auth'
import { probeRemoteServer } from '../application/usecases/dataMigration'
import { localContactRepository } from '../infrastructure/repositories/contactRepository.local'
import ImportFileBlock from '../components/import/ImportFileBlock.vue'
import ExportFormatRow from '../components/import/ExportFormatRow.vue'
const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const fileInput = ref(null)
const selectedFile = ref(null)
const isDragging = ref(false)
const importing = ref(false)
const result = ref(null)
const localFile = ref(null)
const remoteFile = ref(null)
const localImporting = ref(false)
const remoteImporting = ref(false)
const localResult = ref(null)
const remoteResult = ref(null)
const backupPassphrase = ref('')
const busyBackup = ref(false)
const exportFormat = ref('csv')
const exporting = ref(false)
const exportResult = ref(null)
const localExportFormat = ref('csv')
const remoteExportFormat = ref('csv')
const localExporting = ref(false)
const remoteExporting = ref(false)
const localExportResult = ref(null)
const remoteExportResult = ref(null)
const localContactCount = ref(0)
const serverProbeLoading = ref(true)
const serverProbe = ref({ ok: false, message: '', contactCount: 0, relationCount: 0 })
const isAuthenticated = computed(() => Boolean(getStoredAccessToken()))
const canUseRemote = computed(() => isAuthenticated.value && serverProbe.value.ok)
const exportFormatLabels = {
csv: 'CSV',
@@ -162,71 +176,119 @@ const exportFormatLabels = {
vcf: 'vCard',
}
function onFileSelect(e) {
selectedFile.value = e.target.files[0] || null
result.value = null
async function refreshLocalCount() {
const contacts = await localContactRepository.list()
localContactCount.value = contacts.length
}
function onDrop(e) {
isDragging.value = false
const file = e.dataTransfer.files[0]
if (file) { selectedFile.value = file; result.value = null }
async function refreshServerProbe() {
serverProbeLoading.value = true
serverProbe.value = await probeRemoteServer()
serverProbeLoading.value = false
}
async function doImport() {
if (!selectedFile.value) return
importing.value = true
result.value = null
onMounted(async () => {
await Promise.all([refreshLocalCount(), refreshServerProbe()])
})
watch(localFile, () => {
localResult.value = null
})
watch(remoteFile, () => {
remoteResult.value = null
})
function resetLocal() {
localFile.value = null
localResult.value = null
}
function resetRemote() {
remoteFile.value = null
remoteResult.value = null
}
async function doLocalImport() {
if (!localFile.value) return
localImporting.value = true
localResult.value = null
try {
result.value = await store.importContacts(selectedFile.value)
if (result.value.isDump) {
localResult.value = await store.importContactsToLocal(localFile.value)
if (localResult.value.isDump) {
await mapsStore.fetchMaps()
}
await refreshLocalCount()
} catch (e) {
result.value = { error: e.message }
localResult.value = { error: e.message }
} finally {
importing.value = false
localImporting.value = false
}
}
function reset() {
selectedFile.value = null
result.value = null
if (fileInput.value) fileInput.value.value = ''
async function doRemoteImport() {
if (!remoteFile.value || !canUseRemote.value) return
remoteImporting.value = true
remoteResult.value = null
try {
remoteResult.value = await store.importContactsToRemote(remoteFile.value)
await refreshServerProbe()
} catch (e) {
remoteResult.value = { error: e.message }
} finally {
remoteImporting.value = false
}
}
async function doExportContacts() {
exporting.value = true
exportResult.value = null
async function doLocalExport() {
localExporting.value = true
localExportResult.value = null
try {
const { blob, filename, count, format } = await store.exportContacts(exportFormat.value)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
exportResult.value = {
const { blob, filename, count, format } = await store.exportLocalContacts(localExportFormat.value)
downloadBlob(blob, filename)
localExportResult.value = {
count,
formatLabel: exportFormatLabels[format] || format,
}
} catch (e) {
exportResult.value = { error: e?.message || 'Ошибка экспорта' }
localExportResult.value = { error: e?.message || 'Ошибка экспорта' }
} finally {
exporting.value = false
localExporting.value = false
}
}
async function doExport() {
busyBackup.value = true
async function doRemoteExport() {
if (!canUseRemote.value) return
remoteExporting.value = true
remoteExportResult.value = null
try {
const { blob, filename } = await store.exportData(backupPassphrase.value)
const { blob, filename, count, format } = await store.exportRemoteContacts(remoteExportFormat.value)
downloadBlob(blob, filename)
remoteExportResult.value = {
count,
formatLabel: exportFormatLabels[format] || format,
}
} catch (e) {
remoteExportResult.value = { error: e?.message || 'Ошибка экспорта' }
} finally {
remoteExporting.value = false
}
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
async function doExportBackup() {
busyBackup.value = true
try {
const { blob, filename } = await store.exportData(backupPassphrase.value)
downloadBlob(blob, filename)
} finally {
busyBackup.value = false
}
@@ -236,11 +298,11 @@ async function onBackupFileSelect(e) {
const file = e.target.files[0]
if (!file) return
busyBackup.value = true
result.value = null
localResult.value = null
try {
const summary = await store.importDataDump(file, backupPassphrase.value)
await mapsStore.fetchMaps()
result.value = {
localResult.value = {
total: summary.importedContacts + summary.importedRelations,
created: summary.importedContacts,
importedRelations: summary.importedRelations,
@@ -248,8 +310,9 @@ async function onBackupFileSelect(e) {
errors: [],
isDump: true,
}
await refreshLocalCount()
} catch (error) {
result.value = { error: error?.message || 'Ошибка импорта бэкапа' }
localResult.value = { error: error?.message || 'Ошибка импорта бэкапа' }
} finally {
busyBackup.value = false
e.target.value = ''
@@ -258,32 +321,86 @@ async function onBackupFileSelect(e) {
</script>
<style scoped>
.content-narrow {
max-width: 640px;
.format-examples {
margin-bottom: 20px;
}
.format-pre {
font-size: 12px;
color: var(--green);
overflow-x: auto;
margin: 0;
padding: 12px;
background: var(--surface-alt);
border-radius: var(--radius-sm);
}
.import-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
align-items: start;
}
.import-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.import-badge {
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
white-space: nowrap;
}
.import-badge--remote {
background: rgba(78, 204, 163, 0.12);
color: var(--green, #4ecca3);
}
.section-title {
font-size: 16px;
margin: 0;
}
.subsection-title {
font-size: 14px;
margin-bottom: 6px;
margin: 0 0 8px;
}
.section-subtitle {
margin-bottom: 18px;
margin-bottom: 16px;
font-size: 13px;
line-height: 1.45;
}
.drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 36px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
.import-mode-hint {
display: block;
margin-top: 4px;
color: var(--accent);
}
.drop-zone:hover, .drag-over {
border-color: var(--accent);
background: var(--accent-dim);
.import-divider {
margin: 20px 0;
border: none;
border-top: 1px solid var(--border);
}
.export-row {
.import-actions {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
gap: 8px;
}
.import-empty-hint,
.migration-link {
font-size: 12px;
margin: 8px 0 0;
}
.server-status {
font-size: 13px;
margin: 0 0 14px;
}
.server-status--ok {
color: var(--green, #4ecca3);
}
@media (max-width: 960px) {
.import-grid {
grid-template-columns: 1fr;
}
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>