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:
@@ -33,3 +33,8 @@ pnpm-debug.log*
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!deploy/.env.prod.example
|
||||||
|
|
||||||
|
# Production build output (Apache DocumentRoot)
|
||||||
|
deploy/dist/
|
||||||
|
deploy/.env.prod
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ social-graph/
|
|||||||
└── docker-compose.yml
|
└── 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
|
## Запуск без Docker
|
||||||
|
|
||||||
**Backend:**
|
**Backend:**
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
SECRET_KEY = 'django-insecure-social-graph-dev-key-change-in-production'
|
SECRET_KEY = os.environ.get(
|
||||||
DEBUG = True
|
'SECRET_KEY',
|
||||||
ALLOWED_HOSTS = ['*']
|
'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 = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
@@ -25,7 +31,7 @@ ROOT_URLCONF = 'config.urls'
|
|||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
'NAME': os.environ.get('DATABASE_PATH', str(BASE_DIR / 'db.sqlite3')),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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).
|
||||||
@@ -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
|
||||||
|
|
||||||
|
<VirtualHost *:80>
|
||||||
|
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
|
||||||
|
</VirtualHost>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"dexie": "^4.2.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vis-data": "^7.1.9",
|
"vis-data": "^7.1.9",
|
||||||
"vis-network": "^9.1.9",
|
"vis-network": "^9.1.9",
|
||||||
@@ -812,6 +813,12 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"dexie": "^4.2.1",
|
||||||
"vis-network": "^9.1.9",
|
"vis-network": "^9.1.9",
|
||||||
"vis-data": "^7.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 class="network-map-header" v-show="!collapsed">
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ title }}</h2>
|
<h2>{{ title }}</h2>
|
||||||
<p class="network-map-sub">{{ subtitle }}</p>
|
<p v-if="subtitle" class="network-map-sub">{{ subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="network-map-actions">
|
||||||
|
<slot name="filters" />
|
||||||
<button class="btn btn-secondary btn-sm" @click="$emit('fit')">
|
<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">
|
<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" />
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||||
@@ -26,10 +27,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="network-map-toolbar" v-show="!collapsed">
|
|
||||||
<slot name="filters" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-show="!collapsed">
|
<div v-show="!collapsed">
|
||||||
<slot name="legend" />
|
<slot name="legend" />
|
||||||
</div>
|
</div>
|
||||||
@@ -42,8 +39,7 @@ defineProps({
|
|||||||
title: { type: String, default: 'Карта сети' },
|
title: { type: String, default: 'Карта сети' },
|
||||||
subtitle: {
|
subtitle: {
|
||||||
type: String,
|
type: String,
|
||||||
default:
|
default: '',
|
||||||
'Три круга — поддержка, продуктивность, развитие. Секторы — сферы жизни. Толстая линия — частые контакты, пунктир — редкие. Стрелка — от инициатора связи.',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
defineEmits(['toggle-collapse', 'fit'])
|
defineEmits(['toggle-collapse', 'fit'])
|
||||||
@@ -80,7 +76,7 @@ defineEmits(['toggle-collapse', 'fit'])
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 12px 28px 8px;
|
padding: 12px 28px 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
@@ -97,7 +93,15 @@ defineEmits(['toggle-collapse', 'fit'])
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
.network-map-toolbar {
|
.network-map-toolbar {
|
||||||
flex-shrink: 0;
|
display: none;
|
||||||
padding: 0 28px 10px;
|
}
|
||||||
|
.network-map-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 24px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ describe('SearchableSelect', () => {
|
|||||||
props: { modelValue: '', options, placeholder: 'Поиск' },
|
props: { modelValue: '', options, placeholder: 'Поиск' },
|
||||||
})
|
})
|
||||||
const input = wrapper.get('input')
|
const input = wrapper.get('input')
|
||||||
await input.setValue('мария')
|
|
||||||
await input.trigger('focus')
|
await input.trigger('focus')
|
||||||
|
await input.setValue('мария')
|
||||||
|
|
||||||
const items = wrapper.findAll('.searchable-select__option')
|
const items = wrapper.findAll('.searchable-select__option')
|
||||||
expect(items).toHaveLength(1)
|
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'
|
import api from '../api'
|
||||||
|
|
||||||
export async function fetchGraphBundle(graphEndpoint = '/graph/') {
|
export async function fetchGraphBundle(graphEndpoint = '/graph/') {
|
||||||
|
if (isLocalMode()) {
|
||||||
|
return getGraphBundle({ networkMapOnly: graphEndpoint === '/network-map-graph/' })
|
||||||
|
}
|
||||||
const [gRes, rtRes] = await Promise.all([
|
const [gRes, rtRes] = await Promise.all([
|
||||||
api.get(graphEndpoint),
|
api.get(graphEndpoint),
|
||||||
api.get('/relation-types/'),
|
api.get('/relation-types/'),
|
||||||
@@ -13,6 +18,9 @@ export async function fetchGraphBundle(graphEndpoint = '/graph/') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMapChoices() {
|
export async function fetchMapChoices() {
|
||||||
|
if (isLocalMode()) {
|
||||||
|
return getMapChoices()
|
||||||
|
}
|
||||||
const { data } = await api.get('/network-map-choices/')
|
const { data } = await api.get('/network-map-choices/')
|
||||||
return data
|
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 { defineStore } from 'pinia'
|
||||||
import api from '../api'
|
import {
|
||||||
import { fetchAllPages } from '../lib/api/pagination'
|
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', {
|
export const useContactsStore = defineStore('contacts', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -16,7 +36,7 @@ export const useContactsStore = defineStore('contacts', {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
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,
|
totalContacts: (state) => state.contacts.length,
|
||||||
totalRelations: (state) => state.relations.length,
|
totalRelations: (state) => state.relations.length,
|
||||||
},
|
},
|
||||||
@@ -43,33 +63,29 @@ export const useContactsStore = defineStore('contacts', {
|
|||||||
|
|
||||||
async fetchContacts(search = '') {
|
async fetchContacts(search = '') {
|
||||||
return this.withLoading('contactsLoading', async () => {
|
return this.withLoading('contactsLoading', async () => {
|
||||||
const params = search ? { search } : {}
|
this.contacts = await listContacts(search)
|
||||||
this.contacts = await fetchAllPages((page) =>
|
|
||||||
api.get('/contacts/', { params: { ...params, page } })
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchContactById(id) {
|
async fetchContactById(id) {
|
||||||
return this.withLoading('contactsLoading', async () => {
|
return this.withLoading('contactsLoading', async () => {
|
||||||
const { data } = await api.get(`/contacts/${id}/`)
|
const data = await getContactById(id)
|
||||||
const idx = this.contacts.findIndex((c) => c.id === id)
|
const idx = this.contacts.findIndex((c) => String(c.id) === String(id))
|
||||||
if (idx !== -1) this.contacts[idx] = data
|
if (idx !== -1) this.contacts[idx] = data
|
||||||
|
else if (data) this.contacts.push(data)
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchRelations() {
|
async fetchRelations() {
|
||||||
return this.withLoading('relationsLoading', async () => {
|
return this.withLoading('relationsLoading', async () => {
|
||||||
this.relations = await fetchAllPages((page) =>
|
this.relations = await listRelations()
|
||||||
api.get('/relations/', { params: { page } })
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchRelationTypes() {
|
async fetchRelationTypes() {
|
||||||
return this.withLoading('mapLoading', async () => {
|
return this.withLoading('mapLoading', async () => {
|
||||||
const { data } = await api.get('/relation-types/')
|
const data = await getRelationTypes()
|
||||||
this.relationTypes = data
|
this.relationTypes = data
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
@@ -77,7 +93,7 @@ export const useContactsStore = defineStore('contacts', {
|
|||||||
|
|
||||||
async fetchNetworkMapChoices() {
|
async fetchNetworkMapChoices() {
|
||||||
return this.withLoading('mapLoading', async () => {
|
return this.withLoading('mapLoading', async () => {
|
||||||
const { data } = await api.get('/network-map-choices/')
|
const data = await getNetworkMapChoices()
|
||||||
this.mapChoices = data
|
this.mapChoices = data
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
@@ -85,53 +101,68 @@ export const useContactsStore = defineStore('contacts', {
|
|||||||
|
|
||||||
async createContact(payload) {
|
async createContact(payload) {
|
||||||
return this.withLoading('contactsLoading', async () => {
|
return this.withLoading('contactsLoading', async () => {
|
||||||
const { data } = await api.post('/contacts/', payload)
|
const data = await createContactUseCase(payload)
|
||||||
this.contacts.push(data)
|
this.contacts.push(data)
|
||||||
|
await syncPendingChanges()
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateContact(id, payload) {
|
async updateContact(id, payload) {
|
||||||
return this.withLoading('contactsLoading', async () => {
|
return this.withLoading('contactsLoading', async () => {
|
||||||
const { data } = await api.patch(`/contacts/${id}/`, payload)
|
const data = await updateContactUseCase(id, payload)
|
||||||
const idx = this.contacts.findIndex((c) => c.id === id)
|
const idx = this.contacts.findIndex((c) => String(c.id) === String(id))
|
||||||
if (idx !== -1) this.contacts[idx] = data
|
if (idx !== -1) this.contacts[idx] = data
|
||||||
|
await syncPendingChanges()
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteContact(id) {
|
async deleteContact(id) {
|
||||||
return this.withLoading('contactsLoading', async () => {
|
return this.withLoading('contactsLoading', async () => {
|
||||||
await api.delete(`/contacts/${id}/`)
|
await deleteContactUseCase(id)
|
||||||
this.contacts = this.contacts.filter((c) => c.id !== 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) {
|
async createRelation(payload) {
|
||||||
return this.withLoading('relationsLoading', async () => {
|
return this.withLoading('relationsLoading', async () => {
|
||||||
const { data } = await api.post('/relations/', payload)
|
const data = await createRelationUseCase(payload)
|
||||||
this.relations.push(data)
|
this.relations.push(data)
|
||||||
|
await this.fetchContacts()
|
||||||
|
await syncPendingChanges()
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteRelation(id) {
|
async deleteRelation(id) {
|
||||||
return this.withLoading('relationsLoading', async () => {
|
return this.withLoading('relationsLoading', async () => {
|
||||||
await api.delete(`/relations/${id}/`)
|
await deleteRelationUseCase(id)
|
||||||
this.relations = this.relations.filter((r) => r.id !== id)
|
this.relations = this.relations.filter((r) => r.id !== id)
|
||||||
|
await this.fetchContacts()
|
||||||
|
await syncPendingChanges()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async importContacts(file) {
|
async importContacts(file) {
|
||||||
return this.withLoading('mapLoading', async () => {
|
return this.withLoading('contactsLoading', async () => {
|
||||||
const fd = new FormData()
|
const data = await importContactsFromFile(file)
|
||||||
fd.append('file', file)
|
|
||||||
const { data } = await api.post('/import/', fd, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
})
|
|
||||||
await this.fetchContacts()
|
await this.fetchContacts()
|
||||||
|
await syncPendingChanges()
|
||||||
return data
|
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',
|
interaction_intensity: 'intense',
|
||||||
})
|
})
|
||||||
|
|
||||||
const contactId = computed(() => Number(route.params.id))
|
const contactId = computed(() => route.params.id)
|
||||||
|
|
||||||
const contactRelations = computed(() =>
|
const contactRelations = computed(() =>
|
||||||
store.relations.filter(
|
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(() =>
|
const otherContacts = computed(() =>
|
||||||
store.contacts
|
store.contacts
|
||||||
.filter((c) => c.id !== contactId.value)
|
.filter((c) => String(c.id) !== String(contactId.value))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -66,6 +66,31 @@
|
|||||||
</button>
|
</button>
|
||||||
<button v-if="selectedFile" class="btn btn-secondary" style="margin-left:8px;" @click="reset">Сбросить</button>
|
<button v-if="selectedFile" class="btn btn-secondary" style="margin-left:8px;" @click="reset">Сбросить</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,6 +106,8 @@ const selectedFile = ref(null)
|
|||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const importing = ref(false)
|
const importing = ref(false)
|
||||||
const result = ref(null)
|
const result = ref(null)
|
||||||
|
const backupPassphrase = ref('')
|
||||||
|
const busyBackup = ref(false)
|
||||||
|
|
||||||
function onFileSelect(e) {
|
function onFileSelect(e) {
|
||||||
selectedFile.value = e.target.files[0] || null
|
selectedFile.value = e.target.files[0] || null
|
||||||
@@ -111,6 +138,42 @@ function reset() {
|
|||||||
result.value = null
|
result.value = null
|
||||||
if (fileInput.value) fileInput.value.value = ''
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -13,9 +13,6 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #legend>
|
|
||||||
<MapLegendPanel />
|
|
||||||
</template>
|
|
||||||
</NetworkMapTopPanel>
|
</NetworkMapTopPanel>
|
||||||
|
|
||||||
<div class="network-map-body">
|
<div class="network-map-body">
|
||||||
@@ -76,7 +73,6 @@ import {
|
|||||||
} from '../lib/map/positioning'
|
} from '../lib/map/positioning'
|
||||||
import { fetchGraphBundle, fetchMapChoices } from '../composables/useGraphData'
|
import { fetchGraphBundle, fetchMapChoices } from '../composables/useGraphData'
|
||||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||||
import MapLegendPanel from '../components/MapLegendPanel.vue'
|
|
||||||
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
||||||
const GRID_SCALE = 5
|
const GRID_SCALE = 5
|
||||||
|
|
||||||
@@ -113,7 +109,7 @@ async function persistNodePlacement(nodeId, canvasX, canvasY) {
|
|||||||
refreshPositions()
|
refreshPositions()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.updateContact(Number(node.id), {
|
await store.updateContact(node.id, {
|
||||||
life_sphere: nextSphere,
|
life_sphere: nextSphere,
|
||||||
network_circle: nextCircle,
|
network_circle: nextCircle,
|
||||||
map_angle: angle,
|
map_angle: angle,
|
||||||
@@ -122,7 +118,7 @@ async function persistNodePlacement(nodeId, canvasX, canvasY) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Если PATCH не прошел — откатываем карту к данным из API.
|
// Если PATCH не прошел — откатываем карту к данным из API.
|
||||||
await store.fetchContacts()
|
await store.fetchContacts()
|
||||||
const actual = store.contactById(Number(node.id))
|
const actual = store.contactById(node.id)
|
||||||
if (actual) {
|
if (actual) {
|
||||||
node.life_sphere = actual.life_sphere
|
node.life_sphere = actual.life_sphere
|
||||||
node.network_circle = actual.network_circle
|
node.network_circle = actual.network_circle
|
||||||
|
|||||||
Reference in New Issue
Block a user