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:
@@ -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;"]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user