From 5e0af42fce3d8269593d1a7bd32512e94e558356 Mon Sep 17 00:00:00 2001 From: gitrusprus Date: Fri, 29 May 2026 16:08:02 +0300 Subject: [PATCH] Add local-first SPA architecture and containerized production deploy. Move data access to use-cases and IndexedDB repositories with optional remote fallback, changelog/sync ports, and encrypted local backup. Add production Docker Compose (nginx frontend + optional backend) and Apache host proxy configuration for deployment. Co-authored-by: Cursor --- .gitignore | 5 + README.md | 12 ++ backend/Dockerfile.prod | 18 ++ backend/config/settings.py | 14 +- backend/config/wsgi.py | 7 + deploy/.env.prod.example | 12 ++ deploy/README.md | 91 +++++++++ deploy/apache/social-graph.conf | 31 +++ deploy/proxy/nginx-host.conf.example | 24 +++ docker-compose.prod.yml | 32 +++ frontend/Dockerfile.prod | 24 +++ frontend/nginx.conf | 20 ++ frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/application/usecases/contacts.js | 44 ++++ frontend/src/application/usecases/graph.js | 53 +++++ .../src/application/usecases/importExport.js | 189 ++++++++++++++++++ .../src/application/usecases/relations.js | 29 +++ frontend/src/application/usecases/sync.js | 15 ++ .../src/components/NetworkMapTopPanel.vue | 26 ++- .../src/components/SearchableSelect.test.js | 2 +- frontend/src/composables/useGraphData.js | 8 + frontend/src/domain/networkChoices.js | 28 +++ .../src/infrastructure/config/dataMode.js | 14 ++ frontend/src/infrastructure/db/localDb.js | 15 ++ .../repositories/contactRepository.local.js | 94 +++++++++ .../repositories/contactRepository.remote.js | 24 +++ .../repositories/relationRepository.local.js | 60 ++++++ .../repositories/relationRepository.remote.js | 15 ++ .../repositories/repositoryFactory.js | 39 ++++ .../sync/changeLogRepository.js | 36 ++++ .../infrastructure/sync/noopSyncAdapter.js | 11 + .../src/infrastructure/sync/syncAdapter.js | 8 + frontend/src/stores/contacts.js | 83 +++++--- frontend/src/views/ContactDetailView.vue | 6 +- frontend/src/views/ImportView.vue | 63 ++++++ frontend/src/views/NetworkMapView.vue | 8 +- 37 files changed, 1117 insertions(+), 51 deletions(-) create mode 100644 backend/Dockerfile.prod create mode 100644 backend/config/wsgi.py create mode 100644 deploy/.env.prod.example create mode 100644 deploy/README.md create mode 100644 deploy/apache/social-graph.conf create mode 100644 deploy/proxy/nginx-host.conf.example create mode 100644 docker-compose.prod.yml create mode 100644 frontend/Dockerfile.prod create mode 100644 frontend/nginx.conf create mode 100644 frontend/src/application/usecases/contacts.js create mode 100644 frontend/src/application/usecases/graph.js create mode 100644 frontend/src/application/usecases/importExport.js create mode 100644 frontend/src/application/usecases/relations.js create mode 100644 frontend/src/application/usecases/sync.js create mode 100644 frontend/src/domain/networkChoices.js create mode 100644 frontend/src/infrastructure/config/dataMode.js create mode 100644 frontend/src/infrastructure/db/localDb.js create mode 100644 frontend/src/infrastructure/repositories/contactRepository.local.js create mode 100644 frontend/src/infrastructure/repositories/contactRepository.remote.js create mode 100644 frontend/src/infrastructure/repositories/relationRepository.local.js create mode 100644 frontend/src/infrastructure/repositories/relationRepository.remote.js create mode 100644 frontend/src/infrastructure/repositories/repositoryFactory.js create mode 100644 frontend/src/infrastructure/sync/changeLogRepository.js create mode 100644 frontend/src/infrastructure/sync/noopSyncAdapter.js create mode 100644 frontend/src/infrastructure/sync/syncAdapter.js diff --git a/.gitignore b/.gitignore index 06dd0a5..1c0b173 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,8 @@ pnpm-debug.log* .env .env.* !.env.example +!deploy/.env.prod.example + +# Production build output (Apache DocumentRoot) +deploy/dist/ +deploy/.env.prod diff --git a/README.md b/README.md index 1242346..c813f6e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,18 @@ social-graph/ └── docker-compose.yml ``` +## Production (Docker + прокси на хосте) + +См. [deploy/README.md](deploy/README.md). + +Кратко: + +```bash +cp deploy/.env.prod.example deploy/.env.prod +docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d frontend +# прокси на хосте → http://127.0.0.1:8080 (см. deploy/apache/social-graph.conf) +``` + ## Запуск без Docker **Backend:** diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 0000000..e53e88b --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV DJANGO_SETTINGS_MODULE=config.settings + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt gunicorn + +COPY . . + +RUN mkdir -p /app/data + +EXPOSE 8000 + +CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 2 --timeout 120"] diff --git a/backend/config/settings.py b/backend/config/settings.py index 5199339..5e2d4dc 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1,10 +1,16 @@ +import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -SECRET_KEY = 'django-insecure-social-graph-dev-key-change-in-production' -DEBUG = True -ALLOWED_HOSTS = ['*'] +SECRET_KEY = os.environ.get( + 'SECRET_KEY', + 'django-insecure-social-graph-dev-key-change-in-production', +) +DEBUG = os.environ.get('DEBUG', 'True').lower() in ('1', 'true', 'yes') +ALLOWED_HOSTS = [ + h.strip() for h in os.environ.get('ALLOWED_HOSTS', '*').split(',') if h.strip() +] INSTALLED_APPS = [ 'django.contrib.contenttypes', @@ -25,7 +31,7 @@ ROOT_URLCONF = 'config.urls' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'NAME': os.environ.get('DATABASE_PATH', str(BASE_DIR / 'db.sqlite3')), } } diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..a9f185c --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example new file mode 100644 index 0000000..476a785 --- /dev/null +++ b/deploy/.env.prod.example @@ -0,0 +1,12 @@ +# Режим данных при сборке фронта: local | remote | hybrid +VITE_DATA_MODE=local + +# Порты на хосте (для прокси-сервера) +FRONTEND_BIND=127.0.0.1 +FRONTEND_PORT=8080 +BACKEND_BIND=127.0.0.1 +BACKEND_PORT=8000 + +# Только при profile with-backend (VITE_DATA_MODE=remote) +DJANGO_SECRET_KEY=replace-with-long-random-string +ALLOWED_HOSTS=your-domain.com,www.your-domain.com diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..af5ab9b --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,91 @@ +# Production deploy (контейнеры + прокси на хосте) + +Схема: + +```text +[Прокси на хосте :80/:443] + → frontend-контейнер (nginx :8080 на хосте) + → backend-контейнер (gunicorn :8000, только при remote) +``` + +SPA-маршрутизация (`try_files` → `index.html`) выполняется **внутри** frontend-контейнера. + +## 1. Подготовка + +```bash +cd /opt/social-graph +cp deploy/.env.prod.example deploy/.env.prod +# отредактируйте deploy/.env.prod при необходимости +``` + +## 2. Local-first (только frontend) + +Рекомендуется для конфиденциальности — данные в браузере (IndexedDB). + +```bash +docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build frontend +docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d frontend +``` + +Проверка: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/ +# ожидается 200 +``` + +## 3. Прокси на хосте + +### Apache2 + +```bash +sudo a2enmod proxy proxy_http headers +sudo cp deploy/apache/social-graph.conf /etc/apache2/sites-available/social-graph.conf +# ServerName и порты (8080 / 8000) — по вашему .env.prod +sudo a2ensite social-graph.conf +sudo apache2ctl configtest +sudo systemctl reload apache2 +``` + +### nginx на хосте + +См. `deploy/proxy/nginx-host.conf.example`. + +## 4. Remote mode (frontend + backend) + +В `deploy/.env.prod`: + +```env +VITE_DATA_MODE=remote +DJANGO_SECRET_KEY=... +ALLOWED_HOSTS=your-domain.com +``` + +Пересоберите frontend (режим зашивается при build) и поднимите оба сервиса: + +```bash +docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build +docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d frontend +docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d backend +``` + +В `deploy/apache/social-graph.conf` раскомментируйте блок `ProxyPass /api ...` **перед** `ProxyPass /`. + +## 5. Обновление + +```bash +git pull +docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build frontend +docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d frontend +# при remote — также build/up backend +sudo systemctl reload apache2 # или nginx -s reload +``` + +## Порты по умолчанию + +| Сервис | Хост (loopback) | Внутри контейнера | +|----------|-----------------|-------------------| +| frontend | 127.0.0.1:8080 | nginx :80 | +| backend | 127.0.0.1:8000 | gunicorn :8000 | + +Наружу открыт только прокси на хосте (80/443). diff --git a/deploy/apache/social-graph.conf b/deploy/apache/social-graph.conf new file mode 100644 index 0000000..5a974d0 --- /dev/null +++ b/deploy/apache/social-graph.conf @@ -0,0 +1,31 @@ +# Прокси на хосте → контейнеры Docker. +# Скопировать: +# sudo cp deploy/apache/social-graph.conf /etc/apache2/sites-available/social-graph.conf +# +# Перед включением поднимите контейнеры: +# docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d frontend +# # при remote: ... --profile with-backend up -d +# +# sudo a2enmod proxy proxy_http headers rewrite +# sudo a2ensite social-graph.conf +# sudo apache2ctl configtest && sudo systemctl reload apache2 + + + ServerName your-domain.com + ServerAlias www.your-domain.com + + ProxyPreserveHost On + RequestHeader set X-Forwarded-Proto "http" + RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s + + # Backend API (раскомментируйте при VITE_DATA_MODE=remote и profile with-backend) + # ProxyPass /api http://127.0.0.1:8000/api + # ProxyPassReverse /api http://127.0.0.1:8000/api + + # Frontend SPA (nginx в контейнере sg_frontend) + ProxyPass / http://127.0.0.1:8080/ + ProxyPassReverse / http://127.0.0.1:8080/ + + ErrorLog ${APACHE_LOG_DIR}/social-graph-error.log + CustomLog ${APACHE_LOG_DIR}/social-graph-access.log combined + diff --git a/deploy/proxy/nginx-host.conf.example b/deploy/proxy/nginx-host.conf.example new file mode 100644 index 0000000..ce5c3aa --- /dev/null +++ b/deploy/proxy/nginx-host.conf.example @@ -0,0 +1,24 @@ +# Пример для nginx на хосте (не в контейнере). +# Порты должны совпадать с deploy/.env.prod (FRONTEND_PORT, BACKEND_PORT). + +server { + listen 80; + server_name your-domain.com; + + # Раскомментируйте при VITE_DATA_MODE=remote + # location /api/ { + # proxy_pass http://127.0.0.1:8000/api/; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # } + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..942d4dd --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,32 @@ +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + args: + VITE_DATA_MODE: ${VITE_DATA_MODE:-local} + container_name: sg_frontend + ports: + - "${FRONTEND_BIND:-127.0.0.1}:${FRONTEND_PORT:-8080}:80" + restart: unless-stopped + + backend: + profiles: ["with-backend"] + build: + context: ./backend + dockerfile: Dockerfile.prod + container_name: sg_backend + environment: + DJANGO_SETTINGS_MODULE: config.settings + SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me-in-production} + DEBUG: "False" + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1} + DATABASE_PATH: /app/data/db.sqlite3 + volumes: + - sqlite_data:/app/data + ports: + - "${BACKEND_BIND:-127.0.0.1}:${BACKEND_PORT:-8000}:8000" + restart: unless-stopped + +volumes: + sqlite_data: diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..b346baa --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +ARG VITE_DATA_MODE=local +ENV VITE_DATA_MODE=${VITE_DATA_MODE} + +RUN npm run build + +FROM nginx:1.27-alpine AS runtime + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..00badee --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 7d; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5c70bca..6b78f97 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "axios": "^1.6.7", + "dexie": "^4.2.1", "pinia": "^2.1.7", "vis-data": "^7.1.9", "vis-network": "^9.1.9", @@ -812,6 +813,12 @@ "node": ">=0.4.0" } }, + "node_modules/dexie": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.3.tgz", + "integrity": "sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==", + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "license": "MIT", diff --git a/frontend/package.json b/frontend/package.json index 80fb310..69502b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "vue-router": "^4.3.0", "pinia": "^2.1.7", "axios": "^1.6.7", + "dexie": "^4.2.1", "vis-network": "^9.1.9", "vis-data": "^7.1.9" }, diff --git a/frontend/src/application/usecases/contacts.js b/frontend/src/application/usecases/contacts.js new file mode 100644 index 0000000..f0d7e84 --- /dev/null +++ b/frontend/src/application/usecases/contacts.js @@ -0,0 +1,44 @@ +import { appendChange } from '../../infrastructure/sync/changeLogRepository' +import { getContactRepository } from '../../infrastructure/repositories/repositoryFactory' + +const contactRepo = () => getContactRepository() + +export async function listContacts(search = '') { + return contactRepo().list(search) +} + +export async function getContactById(id) { + return contactRepo().getById(id) +} + +export async function createContact(payload) { + const created = await contactRepo().create(payload) + await appendChange({ + entityType: 'contact', + entityId: created.id, + op: 'created', + payloadPatch: created, + }) + return created +} + +export async function updateContact(id, payload) { + const updated = await contactRepo().update(id, payload) + await appendChange({ + entityType: 'contact', + entityId: id, + op: 'updated', + payloadPatch: payload, + }) + return updated +} + +export async function deleteContact(id) { + await contactRepo().remove(id) + await appendChange({ + entityType: 'contact', + entityId: id, + op: 'deleted', + payloadPatch: {}, + }) +} diff --git a/frontend/src/application/usecases/graph.js b/frontend/src/application/usecases/graph.js new file mode 100644 index 0000000..cab55b9 --- /dev/null +++ b/frontend/src/application/usecases/graph.js @@ -0,0 +1,53 @@ +import { listContacts } from './contacts' +import { listRelations } from './relations' +import { getRelationTypes, getNetworkMapChoices } from '../../infrastructure/repositories/repositoryFactory' + +function nodeFromContact(c) { + return { + id: c.id, + label: c.name, + title: [c.organization, c.position, c.email].filter(Boolean).join('\n'), + group: c.organization || 'default', + life_sphere: c.life_sphere, + network_circle: c.network_circle, + importance: c.importance, + map_angle: c.map_angle, + map_radius_ratio: c.map_radius_ratio, + } +} + +function edgeFromRelation(r) { + return { + id: r.id, + from: r.source, + to: r.target, + label: r.relation_type, + title: r.description || r.relation_type, + relation_type: r.relation_type, + interaction_intensity: r.interaction_intensity, + } +} + +export async function getGraphBundle({ networkMapOnly = false } = {}) { + const [contacts, relations, relationTypes] = await Promise.all([ + listContacts(), + listRelations(), + getRelationTypes(), + ]) + + const scopedContacts = networkMapOnly + ? contacts.filter((c) => c.include_on_network_map) + : contacts + const allowedIds = new Set(scopedContacts.map((c) => c.id)) + const scopedRelations = relations.filter((r) => allowedIds.has(r.source) && allowedIds.has(r.target)) + + return { + nodes: scopedContacts.map(nodeFromContact), + edges: scopedRelations.map(edgeFromRelation), + relationTypes, + } +} + +export async function getMapChoices() { + return getNetworkMapChoices() +} diff --git a/frontend/src/application/usecases/importExport.js b/frontend/src/application/usecases/importExport.js new file mode 100644 index 0000000..78d5714 --- /dev/null +++ b/frontend/src/application/usecases/importExport.js @@ -0,0 +1,189 @@ +import { listContacts, createContact } from './contacts' +import { listRelations, createRelation } from './relations' +import { localDb } from '../../infrastructure/db/localDb' + +function isLikelyEmail(value) { + return value.includes('@') && value.includes('.') +} + +function normalizeRows(raw) { + if (Array.isArray(raw)) return raw + if (raw && Array.isArray(raw.contacts)) return raw.contacts + if (raw && Array.isArray(raw.results)) return raw.results + if (raw && Array.isArray(raw.data)) return raw.data + return [] +} + +function parseCsv(text) { + const lines = text.split(/\r?\n/).filter(Boolean) + if (!lines.length) return [] + const headers = lines[0].split(',').map((h) => h.trim()) + return lines.slice(1).map((line) => { + const values = line.split(',') + return headers.reduce((acc, header, idx) => { + acc[header] = (values[idx] || '').trim() + return acc + }, {}) + }) +} + +async function readText(file) { + return file.text() +} + +function toContactPayload(row) { + const name = String(row.name || row.Name || row['ФИО'] || '').trim() + const email = String(row.email || '').trim() + const phone = String(row.phone || '').trim() + const organization = String(row.organization || row.company || '').trim() + const position = String(row.position || row.job || '').trim() + const notes = String(row.notes || row.description || '').trim() + return { + name, + email: email || (isLikelyEmail(phone) ? phone : ''), + phone: isLikelyEmail(phone) ? '' : phone, + organization, + position, + notes, + } +} + +export async function importContactsFromFile(file) { + if (!file) throw new Error('Файл не выбран') + const name = file.name.toLowerCase() + const rawText = await readText(file) + + let rows = [] + if (name.endsWith('.csv')) { + rows = parseCsv(rawText) + } else if (name.endsWith('.json')) { + rows = normalizeRows(JSON.parse(rawText)) + } else { + throw new Error('Поддерживаются только CSV и JSON файлы.') + } + + 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}: отсутствует поле "name"`) + continue + } + await createContact(payload) + created += 1 + } + + return { total: rows.length, created, skipped, errors } +} + +function uint8ToBase64(bytes) { + let binary = '' + bytes.forEach((b) => { + binary += String.fromCharCode(b) + }) + return btoa(binary) +} + +function base64ToUint8(value) { + const binary = atob(value) + const arr = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + arr[i] = binary.charCodeAt(i) + } + return arr +} + +async function deriveKey(passphrase, saltBytes) { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(passphrase), + 'PBKDF2', + false, + ['deriveKey'] + ) + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: saltBytes, + iterations: 210000, + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ) +} + +export async function exportLocalData({ passphrase = '' } = {}) { + const payload = { + version: 1, + exportedAt: new Date().toISOString(), + contacts: await listContacts(), + relations: await listRelations(), + changes: await localDb.changelog.toArray(), + } + + if (!passphrase) { + return { + filename: `social-graph-export-${Date.now()}.json`, + blob: new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }), + } + } + + const iv = crypto.getRandomValues(new Uint8Array(12)) + const salt = crypto.getRandomValues(new Uint8Array(16)) + const key = await deriveKey(passphrase, salt) + const encoded = new TextEncoder().encode(JSON.stringify(payload)) + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded) + const wrapped = { + version: 1, + algorithm: 'AES-GCM', + kdf: 'PBKDF2-SHA256', + iterations: 210000, + salt: uint8ToBase64(salt), + iv: uint8ToBase64(iv), + data: uint8ToBase64(new Uint8Array(encrypted)), + } + return { + filename: `social-graph-export-${Date.now()}.sgpkg`, + blob: new Blob([JSON.stringify(wrapped, null, 2)], { type: 'application/json' }), + } +} + +async function decryptPayload(raw, passphrase) { + const salt = base64ToUint8(raw.salt) + const iv = base64ToUint8(raw.iv) + const encrypted = base64ToUint8(raw.data) + const key = await deriveKey(passphrase, salt) + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted) + return JSON.parse(new TextDecoder().decode(decrypted)) +} + +export async function importLocalDump(file, passphrase = '') { + const raw = JSON.parse(await file.text()) + const dump = raw?.data ? await decryptPayload(raw, passphrase) : raw + + if (!Array.isArray(dump.contacts) || !Array.isArray(dump.relations)) { + throw new Error('Некорректный формат экспортного файла') + } + + await localDb.transaction('rw', localDb.contacts, localDb.relations, localDb.changelog, async () => { + for (const contact of dump.contacts) { + await localDb.contacts.put(contact) + } + for (const relation of dump.relations) { + await localDb.relations.put(relation) + } + if (Array.isArray(dump.changes)) { + for (const change of dump.changes) { + await localDb.changelog.put(change) + } + } + }) + + return { importedContacts: dump.contacts.length, importedRelations: dump.relations.length } +} diff --git a/frontend/src/application/usecases/relations.js b/frontend/src/application/usecases/relations.js new file mode 100644 index 0000000..f9017b6 --- /dev/null +++ b/frontend/src/application/usecases/relations.js @@ -0,0 +1,29 @@ +import { appendChange } from '../../infrastructure/sync/changeLogRepository' +import { getRelationRepository } from '../../infrastructure/repositories/repositoryFactory' + +const relationRepo = () => getRelationRepository() + +export async function listRelations() { + return relationRepo().list() +} + +export async function createRelation(payload) { + const relation = await relationRepo().create(payload) + await appendChange({ + entityType: 'relation', + entityId: relation.id, + op: 'created', + payloadPatch: relation, + }) + return relation +} + +export async function deleteRelation(id) { + await relationRepo().remove(id) + await appendChange({ + entityType: 'relation', + entityId: id, + op: 'deleted', + payloadPatch: {}, + }) +} diff --git a/frontend/src/application/usecases/sync.js b/frontend/src/application/usecases/sync.js new file mode 100644 index 0000000..73df35f --- /dev/null +++ b/frontend/src/application/usecases/sync.js @@ -0,0 +1,15 @@ +import { ackChanges, listPendingChanges } from '../../infrastructure/sync/changeLogRepository' +import { getSyncAdapter } from '../../infrastructure/sync/syncAdapter' +import { isLocalMode } from '../../infrastructure/config/dataMode' + +export async function syncPendingChanges() { + if (isLocalMode()) return { pushed: 0, acknowledged: 0 } + const adapter = getSyncAdapter() + const pending = await listPendingChanges() + if (!pending.length) return { pushed: 0, acknowledged: 0 } + + const response = await adapter.pushChanges(pending) + const ackIds = response?.acknowledgedIds || [] + await ackChanges(ackIds) + return { pushed: pending.length, acknowledged: ackIds.length } +} diff --git a/frontend/src/components/NetworkMapTopPanel.vue b/frontend/src/components/NetworkMapTopPanel.vue index 0edadb2..c13a14d 100644 --- a/frontend/src/components/NetworkMapTopPanel.vue +++ b/frontend/src/components/NetworkMapTopPanel.vue @@ -14,9 +14,10 @@

{{ title }}

-

{{ subtitle }}

+

{{ subtitle }}

-
+
+
-
- -
-
@@ -42,8 +39,7 @@ defineProps({ title: { type: String, default: 'Карта сети' }, subtitle: { type: String, - default: - 'Три круга — поддержка, продуктивность, развитие. Секторы — сферы жизни. Толстая линия — частые контакты, пунктир — редкие. Стрелка — от инициатора связи.', + default: '', }, }) defineEmits(['toggle-collapse', 'fit']) @@ -80,7 +76,7 @@ defineEmits(['toggle-collapse', 'fit']) flex-shrink: 0; padding: 12px 28px 8px; display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 16px; } @@ -97,7 +93,15 @@ defineEmits(['toggle-collapse', 'fit']) line-height: 1.45; } .network-map-toolbar { - flex-shrink: 0; - padding: 0 28px 10px; + display: none; +} +.network-map-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; + padding-left: 16px; + padding-right: 24px; } diff --git a/frontend/src/components/SearchableSelect.test.js b/frontend/src/components/SearchableSelect.test.js index fba0fdf..eb16fb1 100644 --- a/frontend/src/components/SearchableSelect.test.js +++ b/frontend/src/components/SearchableSelect.test.js @@ -13,8 +13,8 @@ describe('SearchableSelect', () => { props: { modelValue: '', options, placeholder: 'Поиск' }, }) const input = wrapper.get('input') - await input.setValue('мария') await input.trigger('focus') + await input.setValue('мария') const items = wrapper.findAll('.searchable-select__option') expect(items).toHaveLength(1) diff --git a/frontend/src/composables/useGraphData.js b/frontend/src/composables/useGraphData.js index d807a24..6036c42 100644 --- a/frontend/src/composables/useGraphData.js +++ b/frontend/src/composables/useGraphData.js @@ -1,6 +1,11 @@ +import { getGraphBundle, getMapChoices } from '../application/usecases/graph' +import { isLocalMode } from '../infrastructure/config/dataMode' import api from '../api' export async function fetchGraphBundle(graphEndpoint = '/graph/') { + if (isLocalMode()) { + return getGraphBundle({ networkMapOnly: graphEndpoint === '/network-map-graph/' }) + } const [gRes, rtRes] = await Promise.all([ api.get(graphEndpoint), api.get('/relation-types/'), @@ -13,6 +18,9 @@ export async function fetchGraphBundle(graphEndpoint = '/graph/') { } export async function fetchMapChoices() { + if (isLocalMode()) { + return getMapChoices() + } const { data } = await api.get('/network-map-choices/') return data } diff --git a/frontend/src/domain/networkChoices.js b/frontend/src/domain/networkChoices.js new file mode 100644 index 0000000..fe23c32 --- /dev/null +++ b/frontend/src/domain/networkChoices.js @@ -0,0 +1,28 @@ +export const RELATION_TYPES = [ + { value: 'colleague', label: 'Коллега' }, + { value: 'friend', label: 'Друг' }, + { value: 'family', label: 'Родственник' }, + { value: 'acquaintance', label: 'Знакомый' }, + { value: 'business', label: 'Деловой партнёр' }, + { value: 'other', label: 'Другое' }, +] + +export const LIFE_SPHERES = [ + { value: 'work', label: 'Работа' }, + { value: 'study', label: 'Учёба' }, + { value: 'hobby', label: 'Хобби' }, + { value: 'family', label: 'Семья' }, + { value: 'health', label: 'Здоровье' }, + { value: 'other', label: 'Другое' }, +] + +export const NETWORK_CIRCLES = [ + { value: 'support', label: 'Круг поддержки' }, + { value: 'productivity', label: 'Круг продуктивности' }, + { value: 'development', label: 'Круг развития' }, +] + +export const INTERACTION_INTENSITIES = [ + { value: 'intense', label: 'Интенсивные контакты' }, + { value: 'sparse', label: 'Редкие контакты' }, +] diff --git a/frontend/src/infrastructure/config/dataMode.js b/frontend/src/infrastructure/config/dataMode.js new file mode 100644 index 0000000..8bbdf91 --- /dev/null +++ b/frontend/src/infrastructure/config/dataMode.js @@ -0,0 +1,14 @@ +const ALLOWED = new Set(['local', 'remote', 'hybrid']) + +function normalizeMode(value) { + const raw = String(value || '').trim().toLowerCase() + return ALLOWED.has(raw) ? raw : 'local' +} + +export function getDataMode() { + return normalizeMode(import.meta.env.VITE_DATA_MODE) +} + +export function isLocalMode() { + return getDataMode() === 'local' +} diff --git a/frontend/src/infrastructure/db/localDb.js b/frontend/src/infrastructure/db/localDb.js new file mode 100644 index 0000000..221ab8a --- /dev/null +++ b/frontend/src/infrastructure/db/localDb.js @@ -0,0 +1,15 @@ +import Dexie from 'dexie' + +class SocialGraphDb extends Dexie { + constructor() { + super('socialGraphDb') + this.version(1).stores({ + contacts: 'id, name, updatedAt, deletedAt, workspaceId', + relations: 'id, source, target, updatedAt, deletedAt, workspaceId', + meta: 'key', + changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId', + }) + } +} + +export const localDb = new SocialGraphDb() diff --git a/frontend/src/infrastructure/repositories/contactRepository.local.js b/frontend/src/infrastructure/repositories/contactRepository.local.js new file mode 100644 index 0000000..fe945fe --- /dev/null +++ b/frontend/src/infrastructure/repositories/contactRepository.local.js @@ -0,0 +1,94 @@ +import { localDb } from '../db/localDb' + +function nowIso() { + return new Date().toISOString() +} + +function withDefaults(payload = {}) { + return { + name: '', + email: '', + phone: '', + organization: '', + position: '', + notes: '', + life_sphere: 'other', + network_circle: 'productivity', + importance: 3, + include_on_network_map: false, + map_angle: null, + map_radius_ratio: null, + ownerId: 'local-user', + workspaceId: 'personal', + ...payload, + } +} + +async function relationsCount(id) { + const [sourceCount, targetCount] = await Promise.all([ + localDb.relations.where('source').equals(id).and((r) => !r.deletedAt).count(), + localDb.relations.where('target').equals(id).and((r) => !r.deletedAt).count(), + ]) + return sourceCount + targetCount +} + +async function hydrate(contact) { + if (!contact || contact.deletedAt) return null + return { + ...contact, + relations_count: await relationsCount(contact.id), + } +} + +export const localContactRepository = { + async list(search = '') { + const all = await localDb.contacts.toArray() + const filtered = all + .filter((c) => !c.deletedAt) + .filter((c) => c.name.toLowerCase().includes(String(search || '').toLowerCase())) + .sort((a, b) => a.name.localeCompare(b.name, 'ru')) + return Promise.all(filtered.map(hydrate)) + }, + + async getById(id) { + const contact = await localDb.contacts.get(id) + return hydrate(contact) + }, + + async create(payload) { + const ts = nowIso() + const record = withDefaults(payload) + const id = crypto.randomUUID() + await localDb.contacts.put({ + ...record, + id, + version: 1, + createdAt: ts, + updatedAt: ts, + deletedAt: null, + }) + return this.getById(id) + }, + + async update(id, payload) { + const existing = await localDb.contacts.get(id) + if (!existing || existing.deletedAt) { + throw new Error('Контакт не найден') + } + await localDb.contacts.update(id, { + ...payload, + updatedAt: nowIso(), + version: Number(existing.version || 1) + 1, + }) + return this.getById(id) + }, + + async remove(id) { + const ts = nowIso() + await localDb.contacts.update(id, { deletedAt: ts, updatedAt: ts }) + const relations = await localDb.relations + .filter((r) => !r.deletedAt && (r.source === id || r.target === id)) + .toArray() + await Promise.all(relations.map((r) => localDb.relations.update(r.id, { deletedAt: ts, updatedAt: ts }))) + }, +} diff --git a/frontend/src/infrastructure/repositories/contactRepository.remote.js b/frontend/src/infrastructure/repositories/contactRepository.remote.js new file mode 100644 index 0000000..f8e0bf8 --- /dev/null +++ b/frontend/src/infrastructure/repositories/contactRepository.remote.js @@ -0,0 +1,24 @@ +import api from '../../api' +import { fetchAllPages } from '../../lib/api/pagination' + +export const remoteContactRepository = { + async list(search = '') { + const params = search ? { search } : {} + return fetchAllPages((page) => api.get('/contacts/', { params: { ...params, page } })) + }, + async getById(id) { + const { data } = await api.get(`/contacts/${id}/`) + return data + }, + async create(payload) { + const { data } = await api.post('/contacts/', payload) + return data + }, + async update(id, payload) { + const { data } = await api.patch(`/contacts/${id}/`, payload) + return data + }, + async remove(id) { + await api.delete(`/contacts/${id}/`) + }, +} diff --git a/frontend/src/infrastructure/repositories/relationRepository.local.js b/frontend/src/infrastructure/repositories/relationRepository.local.js new file mode 100644 index 0000000..020064f --- /dev/null +++ b/frontend/src/infrastructure/repositories/relationRepository.local.js @@ -0,0 +1,60 @@ +import { localDb } from '../db/localDb' + +function nowIso() { + return new Date().toISOString() +} + +async function hydrate(relation) { + if (!relation || relation.deletedAt) return null + const [source, target] = await Promise.all([ + localDb.contacts.get(relation.source), + localDb.contacts.get(relation.target), + ]) + return { + ...relation, + source_name: source?.name || '', + target_name: target?.name || '', + } +} + +export const localRelationRepository = { + async list() { + const all = await localDb.relations.toArray() + const active = all.filter((r) => !r.deletedAt) + const hydrated = await Promise.all(active.map(hydrate)) + return hydrated.filter(Boolean) + }, + + async create(payload) { + if (payload.source === payload.target) { + throw new Error('Нельзя создать связь контакта с самим собой.') + } + const ts = nowIso() + const id = crypto.randomUUID() + await localDb.relations.put({ + id, + source: payload.source, + target: payload.target, + relation_type: payload.relation_type || 'acquaintance', + description: payload.description || '', + interaction_intensity: payload.interaction_intensity || 'intense', + ownerId: 'local-user', + workspaceId: 'personal', + version: 1, + createdAt: ts, + updatedAt: ts, + deletedAt: null, + }) + return hydrate(await localDb.relations.get(id)) + }, + + async remove(id) { + const rel = await localDb.relations.get(id) + if (!rel) return + await localDb.relations.update(id, { + deletedAt: nowIso(), + updatedAt: nowIso(), + version: Number(rel.version || 1) + 1, + }) + }, +} diff --git a/frontend/src/infrastructure/repositories/relationRepository.remote.js b/frontend/src/infrastructure/repositories/relationRepository.remote.js new file mode 100644 index 0000000..d7c0d2a --- /dev/null +++ b/frontend/src/infrastructure/repositories/relationRepository.remote.js @@ -0,0 +1,15 @@ +import api from '../../api' +import { fetchAllPages } from '../../lib/api/pagination' + +export const remoteRelationRepository = { + async list() { + return fetchAllPages((page) => api.get('/relations/', { params: { page } })) + }, + async create(payload) { + const { data } = await api.post('/relations/', payload) + return data + }, + async remove(id) { + await api.delete(`/relations/${id}/`) + }, +} diff --git a/frontend/src/infrastructure/repositories/repositoryFactory.js b/frontend/src/infrastructure/repositories/repositoryFactory.js new file mode 100644 index 0000000..cf52e32 --- /dev/null +++ b/frontend/src/infrastructure/repositories/repositoryFactory.js @@ -0,0 +1,39 @@ +import api from '../../api' +import { RELATION_TYPES, LIFE_SPHERES, NETWORK_CIRCLES, INTERACTION_INTENSITIES } from '../../domain/networkChoices' +import { getDataMode } from '../config/dataMode' +import { localContactRepository } from './contactRepository.local' +import { localRelationRepository } from './relationRepository.local' +import { remoteContactRepository } from './contactRepository.remote' +import { remoteRelationRepository } from './relationRepository.remote' + +function mode() { + return getDataMode() +} + +export function getContactRepository() { + return mode() === 'remote' ? remoteContactRepository : localContactRepository +} + +export function getRelationRepository() { + return mode() === 'remote' ? remoteRelationRepository : localRelationRepository +} + +export async function getRelationTypes() { + if (mode() === 'remote') { + const { data } = await api.get('/relation-types/') + return data + } + return RELATION_TYPES +} + +export async function getNetworkMapChoices() { + if (mode() === 'remote') { + const { data } = await api.get('/network-map-choices/') + return data + } + return { + life_spheres: LIFE_SPHERES, + network_circles: NETWORK_CIRCLES, + interaction_intensities: INTERACTION_INTENSITIES, + } +} diff --git a/frontend/src/infrastructure/sync/changeLogRepository.js b/frontend/src/infrastructure/sync/changeLogRepository.js new file mode 100644 index 0000000..193a9a4 --- /dev/null +++ b/frontend/src/infrastructure/sync/changeLogRepository.js @@ -0,0 +1,36 @@ +import { localDb } from '../db/localDb' + +function nowIso() { + return new Date().toISOString() +} + +export async function appendChange({ entityType, entityId, op, payloadPatch, workspaceId = 'personal' }) { + const id = crypto.randomUUID() + await localDb.changelog.put({ + id, + entityType, + entityId, + op, + payloadPatch, + ts: nowIso(), + workspaceId, + syncStatus: 'pending', + }) +} + +export async function listPendingChanges(limit = 500) { + return localDb.changelog.where('syncStatus').equals('pending').limit(limit).toArray() +} + +export async function ackChanges(ids = []) { + if (!ids.length) return + await localDb.transaction('rw', localDb.changelog, async () => { + await Promise.all( + ids.map((id) => + localDb.changelog.update(id, { + syncStatus: 'acked', + }) + ) + ) + }) +} diff --git a/frontend/src/infrastructure/sync/noopSyncAdapter.js b/frontend/src/infrastructure/sync/noopSyncAdapter.js new file mode 100644 index 0000000..f466c16 --- /dev/null +++ b/frontend/src/infrastructure/sync/noopSyncAdapter.js @@ -0,0 +1,11 @@ +export const noopSyncAdapter = { + async pushChanges() { + return { acknowledgedIds: [] } + }, + async pullChanges() { + return { cursor: null, changes: [] } + }, + async ack() { + return { ok: true } + }, +} diff --git a/frontend/src/infrastructure/sync/syncAdapter.js b/frontend/src/infrastructure/sync/syncAdapter.js new file mode 100644 index 0000000..ced5dfb --- /dev/null +++ b/frontend/src/infrastructure/sync/syncAdapter.js @@ -0,0 +1,8 @@ +import { noopSyncAdapter } from './noopSyncAdapter' + +/** + * @returns {{pushChanges: Function, pullChanges: Function, ack: Function}} + */ +export function getSyncAdapter() { + return noopSyncAdapter +} diff --git a/frontend/src/stores/contacts.js b/frontend/src/stores/contacts.js index 4d211bb..0bb29e1 100644 --- a/frontend/src/stores/contacts.js +++ b/frontend/src/stores/contacts.js @@ -1,6 +1,26 @@ import { defineStore } from 'pinia' -import api from '../api' -import { fetchAllPages } from '../lib/api/pagination' +import { + listContacts, + getContactById, + createContact as createContactUseCase, + updateContact as updateContactUseCase, + deleteContact as deleteContactUseCase, +} from '../application/usecases/contacts' +import { + listRelations, + createRelation as createRelationUseCase, + deleteRelation as deleteRelationUseCase, +} from '../application/usecases/relations' +import { + getRelationTypes, + getNetworkMapChoices, +} from '../infrastructure/repositories/repositoryFactory' +import { + importContactsFromFile, + exportLocalData, + importLocalDump, +} from '../application/usecases/importExport' +import { syncPendingChanges } from '../application/usecases/sync' export const useContactsStore = defineStore('contacts', { state: () => ({ @@ -16,7 +36,7 @@ export const useContactsStore = defineStore('contacts', { }), getters: { - contactById: (state) => (id) => state.contacts.find((c) => c.id === id), + contactById: (state) => (id) => state.contacts.find((c) => String(c.id) === String(id)), totalContacts: (state) => state.contacts.length, totalRelations: (state) => state.relations.length, }, @@ -43,33 +63,29 @@ export const useContactsStore = defineStore('contacts', { async fetchContacts(search = '') { return this.withLoading('contactsLoading', async () => { - const params = search ? { search } : {} - this.contacts = await fetchAllPages((page) => - api.get('/contacts/', { params: { ...params, page } }) - ) + this.contacts = await listContacts(search) }) }, async fetchContactById(id) { return this.withLoading('contactsLoading', async () => { - const { data } = await api.get(`/contacts/${id}/`) - const idx = this.contacts.findIndex((c) => c.id === id) + const data = await getContactById(id) + const idx = this.contacts.findIndex((c) => String(c.id) === String(id)) if (idx !== -1) this.contacts[idx] = data + else if (data) this.contacts.push(data) return data }) }, async fetchRelations() { return this.withLoading('relationsLoading', async () => { - this.relations = await fetchAllPages((page) => - api.get('/relations/', { params: { page } }) - ) + this.relations = await listRelations() }) }, async fetchRelationTypes() { return this.withLoading('mapLoading', async () => { - const { data } = await api.get('/relation-types/') + const data = await getRelationTypes() this.relationTypes = data return data }) @@ -77,7 +93,7 @@ export const useContactsStore = defineStore('contacts', { async fetchNetworkMapChoices() { return this.withLoading('mapLoading', async () => { - const { data } = await api.get('/network-map-choices/') + const data = await getNetworkMapChoices() this.mapChoices = data return data }) @@ -85,53 +101,68 @@ export const useContactsStore = defineStore('contacts', { async createContact(payload) { return this.withLoading('contactsLoading', async () => { - const { data } = await api.post('/contacts/', payload) + const data = await createContactUseCase(payload) this.contacts.push(data) + await syncPendingChanges() return data }) }, async updateContact(id, payload) { return this.withLoading('contactsLoading', async () => { - const { data } = await api.patch(`/contacts/${id}/`, payload) - const idx = this.contacts.findIndex((c) => c.id === id) + const data = await updateContactUseCase(id, payload) + const idx = this.contacts.findIndex((c) => String(c.id) === String(id)) if (idx !== -1) this.contacts[idx] = data + await syncPendingChanges() return data }) }, async deleteContact(id) { return this.withLoading('contactsLoading', async () => { - await api.delete(`/contacts/${id}/`) + await deleteContactUseCase(id) this.contacts = this.contacts.filter((c) => c.id !== id) + this.relations = this.relations.filter((r) => r.source !== id && r.target !== id) + await syncPendingChanges() }) }, async createRelation(payload) { return this.withLoading('relationsLoading', async () => { - const { data } = await api.post('/relations/', payload) + const data = await createRelationUseCase(payload) this.relations.push(data) + await this.fetchContacts() + await syncPendingChanges() return data }) }, async deleteRelation(id) { return this.withLoading('relationsLoading', async () => { - await api.delete(`/relations/${id}/`) + await deleteRelationUseCase(id) this.relations = this.relations.filter((r) => r.id !== id) + await this.fetchContacts() + await syncPendingChanges() }) }, async importContacts(file) { - return this.withLoading('mapLoading', async () => { - const fd = new FormData() - fd.append('file', file) - const { data } = await api.post('/import/', fd, { - headers: { 'Content-Type': 'multipart/form-data' }, - }) + return this.withLoading('contactsLoading', async () => { + const data = await importContactsFromFile(file) await this.fetchContacts() + await syncPendingChanges() return data }) }, + + async exportData(passphrase = '') { + return exportLocalData({ passphrase }) + }, + + async importDataDump(file, passphrase = '') { + const result = await importLocalDump(file, passphrase) + await Promise.all([this.fetchContacts(), this.fetchRelations()]) + return result + }, }, }) diff --git a/frontend/src/views/ContactDetailView.vue b/frontend/src/views/ContactDetailView.vue index 14b5fe8..0a3789d 100644 --- a/frontend/src/views/ContactDetailView.vue +++ b/frontend/src/views/ContactDetailView.vue @@ -160,17 +160,17 @@ const newRel = ref({ interaction_intensity: 'intense', }) -const contactId = computed(() => Number(route.params.id)) +const contactId = computed(() => route.params.id) const contactRelations = computed(() => store.relations.filter( - (r) => r.source === contactId.value || r.target === contactId.value + (r) => String(r.source) === String(contactId.value) || String(r.target) === String(contactId.value) ) ) const otherContacts = computed(() => store.contacts - .filter((c) => c.id !== contactId.value) + .filter((c) => String(c.id) !== String(contactId.value)) .slice() .sort((a, b) => a.name.localeCompare(b.name, 'ru')) ) diff --git a/frontend/src/views/ImportView.vue b/frontend/src/views/ImportView.vue index b75b3f3..1d0d052 100644 --- a/frontend/src/views/ImportView.vue +++ b/frontend/src/views/ImportView.vue @@ -66,6 +66,31 @@
+ +
+

Бэкап локальной базы

+

+ Экспортирует/импортирует локальные данные. Пароль для шифрования необязателен. +

+
+ + +
+
+ + + +
@@ -81,6 +106,8 @@ const selectedFile = ref(null) const isDragging = ref(false) const importing = ref(false) const result = ref(null) +const backupPassphrase = ref('') +const busyBackup = ref(false) function onFileSelect(e) { selectedFile.value = e.target.files[0] || null @@ -111,6 +138,42 @@ function reset() { result.value = null if (fileInput.value) fileInput.value.value = '' } + +async function doExport() { + busyBackup.value = true + try { + const { blob, filename } = await store.exportData(backupPassphrase.value) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) + } finally { + busyBackup.value = false + } +} + +async function onBackupFileSelect(e) { + const file = e.target.files[0] + if (!file) return + busyBackup.value = true + result.value = null + try { + const summary = await store.importDataDump(file, backupPassphrase.value) + result.value = { + total: summary.importedContacts + summary.importedRelations, + created: summary.importedContacts, + skipped: 0, + errors: [], + } + } catch (error) { + result.value = { error: error?.message || 'Ошибка импорта бэкапа' } + } finally { + busyBackup.value = false + e.target.value = '' + } +}