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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:08:02 +03:00
co-authored by Cursor
parent b668ee8507
commit 5e0af42fce
37 changed files with 1117 additions and 51 deletions
+24
View File
@@ -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;"]
+20
View File
@@ -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;
}
}
+7
View File
@@ -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",
+1
View File
@@ -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"
},
@@ -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: {},
})
}
@@ -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()
}
@@ -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 }
}
@@ -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: {},
})
}
+15
View File
@@ -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 }
}
+15 -11
View File
@@ -14,9 +14,10 @@
<div class="network-map-header" v-show="!collapsed">
<div>
<h2>{{ title }}</h2>
<p class="network-map-sub">{{ subtitle }}</p>
<p v-if="subtitle" class="network-map-sub">{{ subtitle }}</p>
</div>
<div class="flex gap-2">
<div class="network-map-actions">
<slot name="filters" />
<button class="btn btn-secondary btn-sm" @click="$emit('fit')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
@@ -26,10 +27,6 @@
</div>
</div>
<div class="network-map-toolbar" v-show="!collapsed">
<slot name="filters" />
</div>
<div v-show="!collapsed">
<slot name="legend" />
</div>
@@ -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;
}
</style>
@@ -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)
+8
View File
@@ -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
}
+28
View File
@@ -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: 'Редкие контакты' },
]
@@ -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'
}
+15
View File
@@ -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()
@@ -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 })))
},
}
@@ -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}/`)
},
}
@@ -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,
})
},
}
@@ -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}/`)
},
}
@@ -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,
}
}
@@ -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',
})
)
)
})
}
@@ -0,0 +1,11 @@
export const noopSyncAdapter = {
async pushChanges() {
return { acknowledgedIds: [] }
},
async pullChanges() {
return { cursor: null, changes: [] }
},
async ack() {
return { ok: true }
},
}
@@ -0,0 +1,8 @@
import { noopSyncAdapter } from './noopSyncAdapter'
/**
* @returns {{pushChanges: Function, pullChanges: Function, ack: Function}}
*/
export function getSyncAdapter() {
return noopSyncAdapter
}
+57 -26
View File
@@ -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
},
},
})
+3 -3
View File
@@ -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'))
)
+63
View File
@@ -66,6 +66,31 @@
</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">
Экспортирует/импортирует локальные данные. Пароль для шифрования необязателен.
</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 ? 'Экспорт...' : 'Экспорт локальной БД' }}
</button>
<button class="btn btn-secondary" style="margin-left:8px;" :disabled="busyBackup" @click="$refs.backupInput.click()">
Импорт бэкапа
</button>
<input
ref="backupInput"
type="file"
accept=".json,.sgpkg"
style="display:none"
@change="onBackupFileSelect"
/>
</div>
</div>
</div>
</div>
@@ -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 = ''
}
}
</script>
<style scoped>
+2 -6
View File
@@ -13,9 +13,6 @@
/>
</template>
<template #legend>
<MapLegendPanel />
</template>
</NetworkMapTopPanel>
<div class="network-map-body">
@@ -76,7 +73,6 @@ import {
} from '../lib/map/positioning'
import { fetchGraphBundle, fetchMapChoices } from '../composables/useGraphData'
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
import MapLegendPanel from '../components/MapLegendPanel.vue'
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
const GRID_SCALE = 5
@@ -113,7 +109,7 @@ async function persistNodePlacement(nodeId, canvasX, canvasY) {
refreshPositions()
try {
await store.updateContact(Number(node.id), {
await store.updateContact(node.id, {
life_sphere: nextSphere,
network_circle: nextCircle,
map_angle: angle,
@@ -122,7 +118,7 @@ async function persistNodePlacement(nodeId, canvasX, canvasY) {
} catch (e) {
// Если PATCH не прошел откатываем карту к данным из API.
await store.fetchContacts()
const actual = store.contactById(Number(node.id))
const actual = store.contactById(node.id)
if (actual) {
node.life_sphere = actual.life_sphere
node.network_circle = actual.network_circle