Cleanup: remove tracked pycache and db.sqlite3; add map membership backfill, new components, backups, docs
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
from contacts.models import Contact, NetworkMap, NetworkMapMembership, Relation
|
||||
|
||||
|
||||
def contact_ids_from_relations(relations_qs):
|
||||
contact_ids = set()
|
||||
for relation in relations_qs.only('source_id', 'target_id'):
|
||||
contact_ids.add(relation.source_id)
|
||||
contact_ids.add(relation.target_id)
|
||||
return contact_ids
|
||||
|
||||
|
||||
def backfill_map_memberships_for_owner(owner):
|
||||
"""Добавляет на первую карту владельца контакты из его связей, если участников ещё нет."""
|
||||
if owner is None:
|
||||
maps = NetworkMap.objects.filter(owner__isnull=True).order_by('id')
|
||||
relations = Relation.objects.filter(owner__isnull=True)
|
||||
else:
|
||||
maps = NetworkMap.objects.filter(owner=owner).order_by('id')
|
||||
relations = Relation.objects.filter(owner=owner)
|
||||
|
||||
if not maps.exists():
|
||||
return 0
|
||||
|
||||
if NetworkMapMembership.objects.filter(map__in=maps).exists():
|
||||
return 0
|
||||
|
||||
contact_ids = contact_ids_from_relations(relations)
|
||||
if not contact_ids:
|
||||
return 0
|
||||
|
||||
if owner is None:
|
||||
contacts = Contact.objects.filter(id__in=contact_ids, owner__isnull=True)
|
||||
else:
|
||||
contacts = Contact.objects.filter(id__in=contact_ids, owner=owner)
|
||||
|
||||
target_map = maps.first()
|
||||
created = 0
|
||||
for contact in contacts:
|
||||
_, was_created = NetworkMapMembership.objects.get_or_create(
|
||||
map=target_map,
|
||||
contact=contact,
|
||||
defaults={
|
||||
'life_sphere': 'other',
|
||||
'network_circle': 'productivity',
|
||||
'importance': 3,
|
||||
'conflict_involvement': 3,
|
||||
},
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
return created
|
||||
|
||||
|
||||
def backfill_all_map_memberships():
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
total = 0
|
||||
total += backfill_map_memberships_for_owner(None)
|
||||
for user in User.objects.all():
|
||||
total += backfill_map_memberships_for_owner(user)
|
||||
return total
|
||||
@@ -0,0 +1,68 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill_memberships(apps, schema_editor):
|
||||
NetworkMap = apps.get_model('contacts', 'NetworkMap')
|
||||
NetworkMapMembership = apps.get_model('contacts', 'NetworkMapMembership')
|
||||
Relation = apps.get_model('contacts', 'Relation')
|
||||
Contact = apps.get_model('contacts', 'Contact')
|
||||
User = apps.get_model('auth', 'User')
|
||||
|
||||
def backfill_for_owner(owner_id):
|
||||
if owner_id is None:
|
||||
maps = NetworkMap.objects.filter(owner__isnull=True).order_by('id')
|
||||
relations = Relation.objects.filter(owner__isnull=True)
|
||||
contact_owner_filter = {'owner__isnull': True}
|
||||
else:
|
||||
maps = NetworkMap.objects.filter(owner_id=owner_id).order_by('id')
|
||||
relations = Relation.objects.filter(owner_id=owner_id)
|
||||
contact_owner_filter = {'owner_id': owner_id}
|
||||
|
||||
if not maps.exists():
|
||||
return 0
|
||||
if NetworkMapMembership.objects.filter(map__in=maps).exists():
|
||||
return 0
|
||||
|
||||
contact_ids = set()
|
||||
for relation in relations.only('source_id', 'target_id'):
|
||||
contact_ids.add(relation.source_id)
|
||||
contact_ids.add(relation.target_id)
|
||||
if not contact_ids:
|
||||
return 0
|
||||
|
||||
contacts = Contact.objects.filter(id__in=contact_ids, **contact_owner_filter)
|
||||
target_map = maps.first()
|
||||
created = 0
|
||||
for contact in contacts:
|
||||
_, was_created = NetworkMapMembership.objects.get_or_create(
|
||||
map=target_map,
|
||||
contact=contact,
|
||||
defaults={
|
||||
'life_sphere': 'other',
|
||||
'network_circle': 'productivity',
|
||||
'importance': 3,
|
||||
'conflict_involvement': 3,
|
||||
},
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
return created
|
||||
|
||||
total = backfill_for_owner(None)
|
||||
for user in User.objects.all():
|
||||
total += backfill_for_owner(user.id)
|
||||
if total:
|
||||
print(f'Backfilled {total} network map memberships')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('contacts', '0011_add_owner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(backfill_memberships, migrations.RunPython.noop),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -142,7 +142,7 @@ class NetworkMapMembershipSerializer(serializers.ModelSerializer):
|
||||
'map_angle', 'map_radius_ratio',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'contact_name', 'map_name']
|
||||
read_only_fields = ['id', 'map', 'created_at', 'updated_at', 'contact_name', 'map_name']
|
||||
|
||||
|
||||
class GraphSerializer(serializers.Serializer):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+22
-17
@@ -29,19 +29,20 @@ def _network_maps_qs(user):
|
||||
return qs
|
||||
|
||||
|
||||
def node_from_contact(contact):
|
||||
def node_from_contact(contact, defaults=None):
|
||||
defaults = defaults or {}
|
||||
return {
|
||||
'id': contact.id,
|
||||
'label': contact.name,
|
||||
'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])),
|
||||
'group': contact.organization or 'default',
|
||||
**defaults,
|
||||
}
|
||||
|
||||
|
||||
def node_from_membership(membership):
|
||||
contact = membership.contact
|
||||
return {
|
||||
**node_from_contact(contact),
|
||||
return node_from_contact(contact, {
|
||||
'life_sphere': membership.life_sphere,
|
||||
'network_circle': membership.network_circle,
|
||||
'importance': membership.importance,
|
||||
@@ -49,7 +50,22 @@ def node_from_membership(membership):
|
||||
'map_angle': membership.map_angle,
|
||||
'map_radius_ratio': membership.map_radius_ratio,
|
||||
'membership_id': membership.id,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
def nodes_for_network_map(network_map, user):
|
||||
memberships = list(
|
||||
NetworkMapMembership.objects.filter(map_id=network_map.id)
|
||||
.select_related('contact', 'map')
|
||||
.order_by('contact__name')
|
||||
)
|
||||
if use_jwt_auth() and user and user.is_authenticated:
|
||||
memberships = [
|
||||
m for m in memberships
|
||||
if m.map.owner_id == user.id and m.contact.owner_id == user.id
|
||||
]
|
||||
|
||||
return [node_from_membership(m) for m in memberships]
|
||||
|
||||
|
||||
def edge_from_relation(relation):
|
||||
@@ -85,19 +101,8 @@ def build_network_map_graph(map_id=None, user=None):
|
||||
if not network_map:
|
||||
return {'nodes': [], 'edges': [], 'conflictology': False, 'conflict_subject': ''}
|
||||
|
||||
memberships = list(
|
||||
NetworkMapMembership.objects.filter(map_id=map_id)
|
||||
.select_related('contact', 'map')
|
||||
.order_by('contact__name')
|
||||
)
|
||||
if use_jwt_auth() and user and user.is_authenticated:
|
||||
memberships = [
|
||||
m for m in memberships
|
||||
if m.map.owner_id == user.id and m.contact.owner_id == user.id
|
||||
]
|
||||
|
||||
allowed_ids = {m.contact_id for m in memberships}
|
||||
nodes = [node_from_membership(m) for m in memberships]
|
||||
nodes = nodes_for_network_map(network_map, user)
|
||||
allowed_ids = {n['id'] for n in nodes}
|
||||
relations = _relations_qs(user)
|
||||
edges = [
|
||||
edge_from_relation(r)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,4 +9,5 @@ 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
|
||||
ALLOWED_HOSTS=social.deepfishing.ru
|
||||
USE_JWT_AUTH=true
|
||||
|
||||
+295
-85
@@ -1,87 +1,130 @@
|
||||
# Production deploy (контейнеры + прокси на хосте)
|
||||
# Развёртывание Social Graph на сервере
|
||||
|
||||
Инструкция для Linux-сервера (Ubuntu/Debian). Приложение публикуется через Docker; снаружи доступен только веб-прокси (80/443).
|
||||
|
||||
> **Production:** [deploy/social.deepfishing.ru.md](./social.deepfishing.ru.md) — инструкция для https://social.deepfishing.ru
|
||||
|
||||
## Схема
|
||||
|
||||
```text
|
||||
[Прокси на хосте :80/:443]
|
||||
→ frontend-контейнер (nginx, 127.0.0.1:8080)
|
||||
→ backend-контейнер (gunicorn, 127.0.0.1:8000 — только при remote)
|
||||
Интернет :80 / :443
|
||||
↓
|
||||
Прокси на хосте (Apache или nginx)
|
||||
↓
|
||||
┌───────────────────────────────────────┐
|
||||
│ frontend-контейнер (nginx) │
|
||||
│ 127.0.0.1:8080 → SPA + статика │
|
||||
└───────────────────────────────────────┘
|
||||
↓ (только в режиме remote)
|
||||
┌───────────────────────────────────────┐
|
||||
│ backend-контейнер (gunicorn) │
|
||||
│ 127.0.0.1:8000 → Django API │
|
||||
│ SQLite в Docker volume │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
SPA-маршрутизация (`try_files` → `index.html`) — **внутри** frontend-контейнера (`frontend/nginx.conf`).
|
||||
| Режим | Где данные | Backend на сервере |
|
||||
|-------|------------|--------------------|
|
||||
| **local** (по умолчанию) | IndexedDB в браузере каждого пользователя | не нужен |
|
||||
| **remote** | SQLite на сервере, общая БД | обязателен |
|
||||
|
||||
## 1. Клонирование и подготовка
|
||||
Режим задаётся при **сборке** фронтенда (`VITE_DATA_MODE`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Требования к серверу
|
||||
|
||||
- Linux (Ubuntu 22.04+ / Debian 12+)
|
||||
- Docker Engine + Docker Compose plugin
|
||||
- Git
|
||||
- Домен, указывающий на IP сервера (для HTTPS)
|
||||
- Apache2 **или** nginx на хосте (reverse proxy)
|
||||
|
||||
Установка Docker (если ещё нет):
|
||||
|
||||
```bash
|
||||
git clone <repo-url> /opt/social-graph
|
||||
cd /opt/social-graph
|
||||
git checkout main # или нужная ветка
|
||||
|
||||
cp deploy/.env.prod.example deploy/.env.prod
|
||||
# отредактируйте deploy/.env.prod
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker "$USER"
|
||||
# перелогиньтесь, чтобы группа docker применилась
|
||||
docker compose version
|
||||
```
|
||||
|
||||
`deploy/.env.prod` в git не коммитится (секреты и локальные порты).
|
||||
---
|
||||
|
||||
## 2. Local-first (рекомендуется)
|
||||
## 2. Клонирование проекта
|
||||
|
||||
Данные пользователя — в браузере (IndexedDB). Backend на сервере **не поднимать**.
|
||||
```bash
|
||||
sudo mkdir -p /opt/social-graph
|
||||
sudo chown "$USER:$USER" /opt/social-graph
|
||||
git clone <URL-репозитория> /opt/social-graph
|
||||
cd /opt/social-graph
|
||||
git checkout main # или нужная ветка
|
||||
```
|
||||
|
||||
В `deploy/.env.prod`:
|
||||
---
|
||||
|
||||
## 3. Конфигурация окружения
|
||||
|
||||
```bash
|
||||
cp deploy/.env.prod.example deploy/.env.prod
|
||||
nano deploy/.env.prod
|
||||
```
|
||||
|
||||
### Вариант A — local-first (данные только в браузере)
|
||||
|
||||
Подходит, если сервер — просто «хостинг интерфейса», без общей базы.
|
||||
|
||||
```env
|
||||
VITE_DATA_MODE=local
|
||||
|
||||
FRONTEND_BIND=127.0.0.1
|
||||
FRONTEND_PORT=8080
|
||||
```
|
||||
|
||||
Backend **не поднимается**. Каждый пользователь хранит данные локально; при смене браузера или устройства данные не переносятся автоматически (экспорт/импорт — через UI → Импорт).
|
||||
|
||||
### Вариант B — remote (данные на сервере, вход по логину)
|
||||
|
||||
Подходит для команды или одного аккаунта с доступом с разных устройств.
|
||||
|
||||
```env
|
||||
VITE_DATA_MODE=remote
|
||||
|
||||
FRONTEND_BIND=127.0.0.1
|
||||
FRONTEND_PORT=8080
|
||||
BACKEND_BIND=127.0.0.1
|
||||
BACKEND_PORT=8000
|
||||
|
||||
DJANGO_SECRET_KEY=сгенерируйте-длинную-случайную-строку
|
||||
ALLOWED_HOSTS=your-domain.com,www.your-domain.com
|
||||
USE_JWT_AUTH=true
|
||||
```
|
||||
|
||||
Сгенерировать секретный ключ:
|
||||
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(50))"
|
||||
```
|
||||
|
||||
Файл `deploy/.env.prod` **не коммитить** — в нём секреты.
|
||||
|
||||
---
|
||||
|
||||
## 4. Сборка и запуск контейнеров
|
||||
|
||||
Перейдите в каталог проекта:
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
```
|
||||
|
||||
### Только frontend (режим local)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build 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` и при необходимости порты (`FRONTEND_PORT` / `BACKEND_PORT` из `.env.prod`).
|
||||
|
||||
```bash
|
||||
sudo a2ensite social-graph.conf
|
||||
sudo apache2ctl configtest
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
HTTPS:
|
||||
|
||||
```bash
|
||||
sudo certbot --apache -d your-domain.com
|
||||
```
|
||||
|
||||
### 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,www.your-domain.com
|
||||
```
|
||||
|
||||
Пересоберите frontend (режим зашивается при build) и поднимите оба сервиса:
|
||||
### Frontend + backend (режим remote)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build
|
||||
@@ -89,48 +132,215 @@ docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --bu
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
|
||||
```
|
||||
|
||||
В `deploy/apache/social-graph.conf` раскомментируйте `ProxyPass /api ...` **выше** блока `ProxyPass /`.
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
sudo a2enmod proxy proxy_http headers
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
curl -s -o /dev/null -w "frontend: %{http_code}\n" http://127.0.0.1:8080/
|
||||
curl -s -o /dev/null -w "backend: %{http_code}\n" http://127.0.0.1:8000/api/v1/meta/choices/
|
||||
```
|
||||
|
||||
Ожидается `200` (backend в режиме remote с JWT может вернуть `401` без токена — это нормально, главное не `502`).
|
||||
|
||||
Логи:
|
||||
|
||||
```bash
|
||||
docker logs sg_frontend --tail 50
|
||||
docker logs sg_backend --tail 50
|
||||
```
|
||||
|
||||
> **Конфликт с dev:** если в том же каталоге запускали `docker compose up`, сначала выполните `docker compose down`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Прокси на хосте
|
||||
|
||||
Контейнеры слушают только `127.0.0.1`. Наружу открывается прокси.
|
||||
|
||||
### Apache2
|
||||
|
||||
```bash
|
||||
sudo apt install apache2
|
||||
sudo a2enmod proxy proxy_http headers rewrite
|
||||
sudo cp deploy/apache/social-graph.conf /etc/apache2/sites-available/social-graph.conf
|
||||
sudo nano /etc/apache2/sites-available/social-graph.conf
|
||||
```
|
||||
|
||||
Измените `ServerName` / `ServerAlias` на ваш домен.
|
||||
|
||||
**При `VITE_DATA_MODE=remote`** раскомментируйте блок API **выше** блока frontend:
|
||||
|
||||
```apache
|
||||
ProxyPass /api http://127.0.0.1:8000/api
|
||||
ProxyPassReverse /api http://127.0.0.1:8000/api
|
||||
```
|
||||
|
||||
Включите сайт:
|
||||
|
||||
```bash
|
||||
sudo a2ensite social-graph.conf
|
||||
sudo a2dissite 000-default.conf # опционально
|
||||
sudo apache2ctl configtest
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
## 5. Обновление
|
||||
HTTPS:
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-apache
|
||||
sudo certbot --apache -d your-domain.com -d www.your-domain.com
|
||||
```
|
||||
|
||||
### nginx на хосте
|
||||
|
||||
```bash
|
||||
sudo apt install nginx
|
||||
sudo cp deploy/proxy/nginx-host.conf.example /etc/nginx/sites-available/social-graph
|
||||
sudo nano /etc/nginx/sites-available/social-graph
|
||||
```
|
||||
|
||||
Укажите `server_name` и при remote-режиме раскомментируйте `location /api/`.
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/social-graph /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
HTTPS:
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Первый вход (режим remote)
|
||||
|
||||
1. Откройте `https://your-domain.com`
|
||||
2. Перейдите на **Регистрация** (`/register`) и создайте аккаунт
|
||||
3. Либо войдите под существующим пользователем
|
||||
|
||||
Если на сервере уже есть данные от пользователя `legacy` (миграция), задайте ему пароль:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --profile with-backend exec backend python manage.py shell
|
||||
```
|
||||
|
||||
```python
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
u = User.objects.get(username='legacy')
|
||||
u.set_password('ваш-пароль')
|
||||
u.save()
|
||||
exit()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Обновление версии
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
git pull
|
||||
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
|
||||
|
||||
# при remote:
|
||||
# docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
|
||||
|
||||
sudo systemctl reload apache2 # или: sudo nginx -s reload
|
||||
```
|
||||
|
||||
## Порты по умолчанию
|
||||
При изменении `VITE_DATA_MODE` нужна **пересборка** frontend (`--build`).
|
||||
|
||||
| Переменная | Значение | Назначение |
|
||||
|------------|----------|------------|
|
||||
| `FRONTEND_PORT` | 8080 | nginx в контейнере `sg_frontend` |
|
||||
| `BACKEND_PORT` | 8000 | gunicorn в контейнере `sg_backend` |
|
||||
| `FRONTEND_BIND` | 127.0.0.1 | только loopback на хосте |
|
||||
---
|
||||
|
||||
Наружу открыт только прокси (80/443).
|
||||
## 8. Резервное копирование (remote)
|
||||
|
||||
## Миграция данных
|
||||
|
||||
| Источник | Действие |
|
||||
|----------|----------|
|
||||
| CSV / JSON / **vCard (.vcf)** | UI → Импорт |
|
||||
| Старый Django SQLite | экспорт JSON → Импорт |
|
||||
| Локальный бэкап `.json` / `.sgpkg` | UI → «Импорт бэкапа» |
|
||||
|
||||
## Устранение неполадок
|
||||
База — SQLite в Docker volume `sqlite_data`.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
docker logs sg_frontend --tail 50
|
||||
docker logs sg_backend --tail 50 # если поднят
|
||||
docker compose -f docker-compose.prod.yml --profile with-backend exec backend \
|
||||
python manage.py dumpdata contacts --indent 2 > backup-contacts-$(date +%F).json
|
||||
```
|
||||
|
||||
Конфликт имени контейнера с dev: остановите `docker compose down` в том же каталоге перед prod-запуском.
|
||||
Полный дамп через UI: **Импорт** → экспорт бэкапа (если включён в интерфейсе).
|
||||
|
||||
Копия файла БД (осторожно — только при остановленном backend):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --profile with-backend stop backend
|
||||
docker run --rm -v social-graph_sqlite_data:/data -v "$PWD":/backup alpine \
|
||||
cp /data/db.sqlite3 /backup/db.sqlite3-$(date +%F)
|
||||
docker compose -f docker-compose.prod.yml --profile with-backend start backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Порты и безопасность
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|------------|--------------|------------|
|
||||
| `FRONTEND_PORT` | 8080 | nginx в контейнере |
|
||||
| `BACKEND_PORT` | 8000 | gunicorn |
|
||||
| `FRONTEND_BIND` | 127.0.0.1 | только localhost |
|
||||
|
||||
Рекомендуется:
|
||||
|
||||
```bash
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 'Apache Full' # или 'Nginx Full'
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
Порты 8080 и 8000 **не** открывать наружу — только через прокси.
|
||||
|
||||
---
|
||||
|
||||
## 10. Устранение неполадок
|
||||
|
||||
| Симптом | Что проверить |
|
||||
|---------|----------------|
|
||||
| 502 Bad Gateway | Контейнеры запущены? `docker ps`, логи `sg_frontend` / `sg_backend` |
|
||||
| Белая страница после деплоя | Пересобран frontend? `VITE_DATA_MODE` совпадает с ожиданиями |
|
||||
| API не отвечает в remote | Раскомментирован `ProxyPass /api` в Apache/nginx; backend в profile `with-backend` |
|
||||
| «Сессия истекла» / 401 | `USE_JWT_AUTH=true` на backend; перелогин |
|
||||
| Данные не общие между ПК | Нужен `VITE_DATA_MODE=remote`, не `local` |
|
||||
|
||||
Полезные команды:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod ps
|
||||
docker logs sg_frontend --tail 100
|
||||
docker logs sg_backend --tail 100
|
||||
docker compose -f docker-compose.prod.yml --profile with-backend exec backend python manage.py migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Краткая шпаргалка
|
||||
|
||||
**Local (только UI):**
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
cp deploy/.env.prod.example deploy/.env.prod # VITE_DATA_MODE=local
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
|
||||
# настроить Apache/nginx → 127.0.0.1:8080
|
||||
```
|
||||
|
||||
**Remote (серверная БД + авторизация):**
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
cp deploy/.env.prod.example deploy/.env.prod
|
||||
# VITE_DATA_MODE=remote, DJANGO_SECRET_KEY, ALLOWED_HOSTS, USE_JWT_AUTH=true
|
||||
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 --build frontend
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
|
||||
# прокси: / → :8080, /api → :8000
|
||||
```
|
||||
|
||||
Дополнительно: [README.md](../README.md), разработка — `docker compose up --build` (порты 5173 и 8000).
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
# Развёртывание Social Graph на [social.deepfishing.ru](https://social.deepfishing.ru)
|
||||
|
||||
Инструкция для production-сервера **social.deepfishing.ru**. Приложение публикуется через Docker; снаружи доступен только веб-прокси (80/443).
|
||||
|
||||
**Адрес приложения:** https://social.deepfishing.ru
|
||||
|
||||
---
|
||||
|
||||
## Схема
|
||||
|
||||
```text
|
||||
https://social.deepfishing.ru (:443)
|
||||
↓
|
||||
Прокси на хосте (Apache или nginx)
|
||||
↓
|
||||
┌───────────────────────────────────────┐
|
||||
│ frontend-контейнер (nginx) │
|
||||
│ 127.0.0.1:8080 → SPA + статика │
|
||||
└───────────────────────────────────────┘
|
||||
↓ (режим remote)
|
||||
┌───────────────────────────────────────┐
|
||||
│ backend-контейнер (gunicorn) │
|
||||
│ 127.0.0.1:8000 → Django API │
|
||||
│ SQLite в Docker volume │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Режим | Где данные | Backend |
|
||||
|-------|------------|---------|
|
||||
| **local** | IndexedDB в браузере | не нужен |
|
||||
| **remote** | SQLite на сервере | обязателен |
|
||||
|
||||
Для **social.deepfishing.ru** рекомендуется **remote** + `USE_JWT_AUTH=true` (регистрация, вход, общая база).
|
||||
|
||||
---
|
||||
|
||||
## 1. Требования
|
||||
|
||||
- Linux (Ubuntu 22.04+ / Debian 12+)
|
||||
- Docker Engine + Docker Compose
|
||||
- Git
|
||||
- DNS: запись **A** (или **AAAA**) `social.deepfishing.ru` → IP сервера
|
||||
- Apache2 **или** nginx на хосте
|
||||
|
||||
Установка Docker:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker "$USER"
|
||||
# перелогиньтесь
|
||||
docker compose version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Клонирование
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/social-graph
|
||||
sudo chown "$USER:$USER" /opt/social-graph
|
||||
git clone <URL-репозитория> /opt/social-graph
|
||||
cd /opt/social-graph
|
||||
git checkout main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Конфигурация `deploy/.env.prod`
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
cp deploy/.env.prod.example deploy/.env.prod
|
||||
nano deploy/.env.prod
|
||||
```
|
||||
|
||||
### Рекомендуемый конфиг для social.deepfishing.ru (remote)
|
||||
|
||||
```env
|
||||
VITE_DATA_MODE=remote
|
||||
|
||||
FRONTEND_BIND=127.0.0.1
|
||||
FRONTEND_PORT=8080
|
||||
BACKEND_BIND=127.0.0.1
|
||||
BACKEND_PORT=8000
|
||||
|
||||
DJANGO_SECRET_KEY=<сгенерируйте-длинную-случайную-строку>
|
||||
ALLOWED_HOSTS=social.deepfishing.ru
|
||||
USE_JWT_AUTH=true
|
||||
```
|
||||
|
||||
Сгенерировать `DJANGO_SECRET_KEY`:
|
||||
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(50))"
|
||||
```
|
||||
|
||||
### Альтернатива — только UI (local)
|
||||
|
||||
Если backend не нужен, данные останутся в браузере каждого пользователя:
|
||||
|
||||
```env
|
||||
VITE_DATA_MODE=local
|
||||
FRONTEND_BIND=127.0.0.1
|
||||
FRONTEND_PORT=8080
|
||||
```
|
||||
|
||||
Файл `deploy/.env.prod` **не коммитить**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Запуск контейнеров
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
```
|
||||
|
||||
### Remote (frontend + backend)
|
||||
|
||||
```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 --build frontend
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
|
||||
```
|
||||
|
||||
### Только frontend (local)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
|
||||
```
|
||||
|
||||
### Проверка на сервере
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
curl -s -o /dev/null -w "frontend: %{http_code}\n" http://127.0.0.1:8080/
|
||||
curl -s -o /dev/null -w "backend: %{http_code}\n" http://127.0.0.1:8000/api/v1/meta/choices/
|
||||
curl -s -o /dev/null -w "public: %{http_code}\n" https://social.deepfishing.ru/
|
||||
```
|
||||
|
||||
Логи:
|
||||
|
||||
```bash
|
||||
docker logs sg_frontend --tail 50
|
||||
docker logs sg_backend --tail 50
|
||||
```
|
||||
|
||||
> Перед prod-запуском остановите dev-контейнеры: `docker compose down`
|
||||
|
||||
---
|
||||
|
||||
## 5. Прокси на хосте
|
||||
|
||||
Контейнеры слушают **только** `127.0.0.1`. Снаружи — прокси на 80/443.
|
||||
|
||||
### Apache2
|
||||
|
||||
```bash
|
||||
sudo apt install apache2
|
||||
sudo a2enmod proxy proxy_http headers rewrite ssl
|
||||
sudo cp deploy/apache/social-graph.conf /etc/apache2/sites-available/social-graph.conf
|
||||
sudo nano /etc/apache2/sites-available/social-graph.conf
|
||||
```
|
||||
|
||||
Пример VirtualHost:
|
||||
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName social.deepfishing.ru
|
||||
|
||||
ProxyPreserveHost On
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
|
||||
|
||||
# API (режим remote)
|
||||
ProxyPass /api http://127.0.0.1:8000/api
|
||||
ProxyPassReverse /api http://127.0.0.1:8000/api
|
||||
|
||||
# Frontend SPA
|
||||
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>
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo a2ensite social-graph.conf
|
||||
sudo a2dissite 000-default.conf
|
||||
sudo apache2ctl configtest
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
HTTPS (Let's Encrypt):
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-apache
|
||||
sudo certbot --apache -d social.deepfishing.ru
|
||||
```
|
||||
|
||||
После certbot сайт будет доступен по https://social.deepfishing.ru .
|
||||
|
||||
### nginx на хосте
|
||||
|
||||
```bash
|
||||
sudo apt install nginx
|
||||
sudo nano /etc/nginx/sites-available/social-graph
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name social.deepfishing.ru;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/social-graph /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d social.deepfishing.ru
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Первый вход
|
||||
|
||||
1. Откройте https://social.deepfishing.ru
|
||||
2. **Регистрация:** https://social.deepfishing.ru/register
|
||||
3. Или **вход:** https://social.deepfishing.ru/login
|
||||
|
||||
Если на сервере есть пользователь `legacy` (данные после миграции), задайте пароль:
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
docker compose -f docker-compose.prod.yml --profile with-backend exec backend python manage.py shell
|
||||
```
|
||||
|
||||
```python
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
u = User.objects.get(username='legacy')
|
||||
u.set_password('ваш-надёжный-пароль')
|
||||
u.save()
|
||||
exit()
|
||||
```
|
||||
|
||||
Вход: https://social.deepfishing.ru/login → логин `legacy`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Обновление
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
git pull
|
||||
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
|
||||
|
||||
sudo systemctl reload apache2 # или: sudo nginx -s reload
|
||||
```
|
||||
|
||||
Проверка: https://social.deepfishing.ru
|
||||
|
||||
---
|
||||
|
||||
## 8. Резервное копирование
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
docker compose -f docker-compose.prod.yml --profile with-backend exec backend \
|
||||
python manage.py dumpdata contacts --indent 2 \
|
||||
> backup-social-deepfishing-$(date +%F).json
|
||||
```
|
||||
|
||||
Копия SQLite (backend должен быть остановлен):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --profile with-backend stop backend
|
||||
docker run --rm -v social-graph_sqlite_data:/data -v "$PWD":/backup alpine \
|
||||
cp /data/db.sqlite3 /backup/db-social-deepfishing-$(date +%F).sqlite3
|
||||
docker compose -f docker-compose.prod.yml --profile with-backend start backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Безопасность
|
||||
|
||||
```bash
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 'Apache Full' # или 'Nginx Full'
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
| Порт | Доступ |
|
||||
|------|--------|
|
||||
| 443, 80 | открыт (прокси) |
|
||||
| 8080, 8000 | только 127.0.0.1 |
|
||||
|
||||
---
|
||||
|
||||
## 10. Устранение неполадок
|
||||
|
||||
| Симптом | Решение |
|
||||
|---------|---------|
|
||||
| 502 на https://social.deepfishing.ru | `docker ps`, логи `sg_frontend` / `sg_backend` |
|
||||
| Страница открывается, API 404 | В прокси включён `ProxyPass /api` → `:8000` |
|
||||
| 401 / не пускает | `USE_JWT_AUTH=true` в `.env.prod`, пересобрать backend |
|
||||
| Данные не сохраняются между устройствами | `VITE_DATA_MODE=remote`, пересобрать frontend |
|
||||
| Белый экран | `docker logs sg_frontend`, пересборка с `--build` |
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod ps
|
||||
docker logs sg_frontend --tail 100
|
||||
docker logs sg_backend --tail 100
|
||||
docker compose -f docker-compose.prod.yml --profile with-backend exec backend python manage.py migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Шпаргалка (social.deepfishing.ru, remote)
|
||||
|
||||
```bash
|
||||
cd /opt/social-graph
|
||||
cp deploy/.env.prod.example deploy/.env.prod
|
||||
# ALLOWED_HOSTS=social.deepfishing.ru
|
||||
# VITE_DATA_MODE=remote
|
||||
# USE_JWT_AUTH=true
|
||||
# DJANGO_SECRET_KEY=...
|
||||
|
||||
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 --build frontend
|
||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
|
||||
|
||||
# Apache: ServerName social.deepfishing.ru
|
||||
# ProxyPass /api → 127.0.0.1:8000
|
||||
# ProxyPass / → 127.0.0.1:8080
|
||||
# certbot --apache -d social.deepfishing.ru
|
||||
```
|
||||
|
||||
**Проверка:** https://social.deepfishing.ru
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me-in-production}
|
||||
DEBUG: "False"
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||
USE_JWT_AUTH: ${USE_JWT_AUTH:-false}
|
||||
DATABASE_PATH: /app/data/db.sqlite3
|
||||
volumes:
|
||||
- sqlite_data:/app/data
|
||||
|
||||
@@ -83,4 +83,19 @@ describe('getGraphBundle', () => {
|
||||
expect(bundle.edges).toHaveLength(1)
|
||||
expect(bundle.edges[0].id).toBe('10')
|
||||
})
|
||||
|
||||
it('returns empty graph when map has no memberships', async () => {
|
||||
listContacts.mockResolvedValue([
|
||||
{ id: '1', name: 'Анна' },
|
||||
{ id: '2', name: 'Борис' },
|
||||
])
|
||||
listRelations.mockResolvedValue([
|
||||
{ id: '10', source: '1', target: '2', relation_type: 'friend' },
|
||||
])
|
||||
listMembershipsByMap.mockResolvedValue([])
|
||||
|
||||
const bundle = await getGraphBundle({ mapId: 'map-1' })
|
||||
expect(bundle.nodes).toHaveLength(0)
|
||||
expect(bundle.edges).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
const mapRepo = () => getNetworkMapRepository()
|
||||
const membershipRepo = () => getNetworkMapMembershipRepository()
|
||||
|
||||
export const DEFAULT_NETWORK_MAP_NAME = 'Основная карта'
|
||||
|
||||
export async function listNetworkMaps() {
|
||||
return mapRepo().list()
|
||||
}
|
||||
@@ -26,6 +28,19 @@ export async function createNetworkMap(payload) {
|
||||
return created
|
||||
}
|
||||
|
||||
export async function ensureDefaultNetworkMap() {
|
||||
const maps = await listNetworkMaps()
|
||||
if (maps.length) return maps[0]
|
||||
|
||||
const { getDefaultNetworkMapType } = await import('./networkMapTypes')
|
||||
const defaultType = await getDefaultNetworkMapType()
|
||||
return createNetworkMap({
|
||||
name: DEFAULT_NETWORK_MAP_NAME,
|
||||
description: '',
|
||||
mapTypeId: defaultType?.id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateNetworkMap(id, payload) {
|
||||
const updated = await mapRepo().update(id, payload)
|
||||
await appendChange({
|
||||
|
||||
@@ -1,72 +1,257 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Добавить на карту</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Контакт</label>
|
||||
<SearchableSelect
|
||||
v-model="selectedContactId"
|
||||
:options="contactOptions"
|
||||
placeholder="Выберите контакт..."
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedContactId"
|
||||
@click="onAdd"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-overlay add-contact-modal" @click.self="onClose">
|
||||
<div class="modal" role="dialog" aria-labelledby="add-contact-title">
|
||||
<div class="modal-header">
|
||||
<h3 id="add-contact-title">Добавить на карту</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="onClose">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="add-contact-search">Поиск контакта</label>
|
||||
<input
|
||||
id="add-contact-search"
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="form-control"
|
||||
placeholder="Введите имя..."
|
||||
autocomplete="off"
|
||||
@keydown.enter.prevent="selectFirstResult"
|
||||
/>
|
||||
<p v-if="!searchQuery.trim()" class="field-hint text-muted">
|
||||
Начните вводить имя — появится список контактов.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="searchQuery.trim()" class="contact-results">
|
||||
<p v-if="searching" class="text-muted results-status">Поиск…</p>
|
||||
<p v-else-if="!searchResults.length" class="text-muted results-status">Ничего не найдено</p>
|
||||
<button
|
||||
v-for="contact in searchResults"
|
||||
:key="contact.id"
|
||||
type="button"
|
||||
class="contact-result"
|
||||
:class="{ 'is-selected': String(selectedContactId) === String(contact.id) }"
|
||||
@click="selectContact(contact)"
|
||||
>
|
||||
<span class="contact-result__name">{{ contact.name }}</span>
|
||||
<span v-if="contact.organization" class="contact-result__meta">{{ contact.organization }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedContact" class="selected-summary">
|
||||
Выбран: <strong>{{ selectedContact.name }}</strong>
|
||||
</p>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="onClose">Отмена</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedContactId || saving"
|
||||
@click="submit"
|
||||
>
|
||||
{{ saving ? 'Добавление…' : 'Добавить' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { listContacts } from '../application/usecases/contacts'
|
||||
import { normalizeApiError } from '../lib/api/errors'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
contacts: { type: Array, default: () => [] },
|
||||
memberContactIds: { type: Array, default: () => [] },
|
||||
onAdd: { type: Function, required: true },
|
||||
})
|
||||
const emit = defineEmits(['close', 'add'])
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const searchInputRef = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const selectedContactId = ref('')
|
||||
const searching = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
let searchTimer = null
|
||||
let searchRequestId = 0
|
||||
|
||||
const memberSet = computed(() => new Set(props.memberContactIds.map(String)))
|
||||
|
||||
const contactOptions = computed(() =>
|
||||
props.contacts
|
||||
.filter((c) => !memberSet.value.has(String(c.id)))
|
||||
.map((c) => ({ value: String(c.id), label: c.name }))
|
||||
const selectedContact = computed(() =>
|
||||
searchResults.value.find((c) => String(c.id) === String(selectedContactId.value)) || null
|
||||
)
|
||||
|
||||
function resetState() {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
selectedContactId.value = ''
|
||||
searching.value = false
|
||||
saving.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function selectContact(contact) {
|
||||
selectedContactId.value = String(contact.id)
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function selectFirstResult() {
|
||||
const first = searchResults.value[0]
|
||||
if (first) selectContact(first)
|
||||
}
|
||||
|
||||
async function runSearch(query) {
|
||||
const requestId = ++searchRequestId
|
||||
searching.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const rows = await listContacts(query)
|
||||
if (requestId !== searchRequestId) return
|
||||
searchResults.value = rows.filter((c) => !memberSet.value.has(String(c.id)))
|
||||
if (
|
||||
selectedContactId.value
|
||||
&& !searchResults.value.some((c) => String(c.id) === String(selectedContactId.value))
|
||||
) {
|
||||
selectedContactId.value = ''
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestId !== searchRequestId) return
|
||||
searchResults.value = []
|
||||
error.value = normalizeApiError(e).message
|
||||
} finally {
|
||||
if (requestId === searchRequestId) {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) selectedContactId.value = ''
|
||||
async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
resetState()
|
||||
await nextTick()
|
||||
searchInputRef.value?.focus()
|
||||
}
|
||||
)
|
||||
|
||||
function onAdd() {
|
||||
if (!selectedContactId.value) return
|
||||
emit('add', selectedContactId.value)
|
||||
watch(searchQuery, (value) => {
|
||||
clearTimeout(searchTimer)
|
||||
selectedContactId.value = ''
|
||||
error.value = ''
|
||||
|
||||
const query = value.trim()
|
||||
if (!query) {
|
||||
searchResults.value = []
|
||||
searching.value = false
|
||||
return
|
||||
}
|
||||
|
||||
searchTimer = setTimeout(() => {
|
||||
runSearch(query)
|
||||
}, 250)
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!selectedContactId.value || saving.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await props.onAdd(selectedContactId.value)
|
||||
} catch (e) {
|
||||
error.value = normalizeApiError(e).message
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-contact-modal {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.field-hint,
|
||||
.results-status {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.contact-results {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.contact-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.contact-result:hover,
|
||||
.contact-result.is-selected {
|
||||
background: rgba(91, 141, 238, 0.12);
|
||||
}
|
||||
|
||||
.contact-result__name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.contact-result__meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selected-summary {
|
||||
margin: 14px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--red, #e74c3c);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="onCancel">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Новый контакт</h3>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="onCancel">✕</button>
|
||||
</div>
|
||||
<ContactForm
|
||||
:initial="{}"
|
||||
:initial-map-ids="initialMapIds"
|
||||
@submit="onSubmit"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ContactForm from './ContactForm.vue'
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
initialMapIds: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
|
||||
function onCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onSubmit(contactData, mapIds, pluginPayload) {
|
||||
emit('created', contactData, mapIds, pluginPayload)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="graph-node-menu-overlay"
|
||||
@click="close"
|
||||
@contextmenu.prevent="close"
|
||||
/>
|
||||
<div
|
||||
v-if="open"
|
||||
class="graph-node-menu"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
role="menu"
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onCreateContact">
|
||||
Добавить контакт
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'create-contact'])
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onCreateContact() {
|
||||
emit('create-contact')
|
||||
close()
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.key === 'Escape' && props.open) close()
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) window.addEventListener('keydown', onKeyDown)
|
||||
else window.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.open) window.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-node-menu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
}
|
||||
.graph-node-menu {
|
||||
position: fixed;
|
||||
z-index: 901;
|
||||
min-width: 180px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 6px 0;
|
||||
}
|
||||
.graph-node-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.graph-node-menu__item:hover {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<ul v-if="open && filteredOptions.length" class="searchable-select__list" role="listbox">
|
||||
<ul v-if="open && query.trim() && filteredOptions.length" class="searchable-select__list" role="listbox">
|
||||
<li
|
||||
v-for="(opt, index) in filteredOptions"
|
||||
:key="String(opt.value)"
|
||||
@@ -32,6 +32,9 @@
|
||||
{{ opt.label }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="open && !query.trim()" class="searchable-select__empty">
|
||||
Начните вводить имя для поиска
|
||||
</p>
|
||||
<p v-else-if="open && query.trim() && !filteredOptions.length" class="searchable-select__empty">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
@@ -62,7 +65,7 @@ const allOptions = computed(() =>
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase('ru')
|
||||
if (!q) return allOptions.value
|
||||
if (!q) return []
|
||||
return allOptions.value.filter((o) =>
|
||||
o.label.toLocaleLowerCase('ru').includes(q)
|
||||
)
|
||||
|
||||
@@ -9,11 +9,15 @@ export function useGraphNodeContextMenu() {
|
||||
const contextMenuEdge = ref(null)
|
||||
const edgeContextMenuX = ref(0)
|
||||
const edgeContextMenuY = ref(0)
|
||||
const canvasContextMenuOpen = ref(false)
|
||||
const canvasContextMenuX = ref(0)
|
||||
const canvasContextMenuY = ref(0)
|
||||
|
||||
function openContextMenu(node, event) {
|
||||
if (!node || !event) return
|
||||
edgeContextMenuOpen.value = false
|
||||
contextMenuEdge.value = null
|
||||
canvasContextMenuOpen.value = false
|
||||
contextMenuNode.value = node
|
||||
contextMenuX.value = event.clientX
|
||||
contextMenuY.value = event.clientY
|
||||
@@ -24,17 +28,30 @@ export function useGraphNodeContextMenu() {
|
||||
if (!edge || !event) return
|
||||
contextMenuOpen.value = false
|
||||
contextMenuNode.value = null
|
||||
canvasContextMenuOpen.value = false
|
||||
contextMenuEdge.value = edge
|
||||
edgeContextMenuX.value = event.clientX
|
||||
edgeContextMenuY.value = event.clientY
|
||||
edgeContextMenuOpen.value = true
|
||||
}
|
||||
|
||||
function openCanvasContextMenu(event) {
|
||||
if (!event) return
|
||||
contextMenuOpen.value = false
|
||||
contextMenuNode.value = null
|
||||
edgeContextMenuOpen.value = false
|
||||
contextMenuEdge.value = null
|
||||
canvasContextMenuX.value = event.clientX
|
||||
canvasContextMenuY.value = event.clientY
|
||||
canvasContextMenuOpen.value = true
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenuOpen.value = false
|
||||
contextMenuNode.value = null
|
||||
edgeContextMenuOpen.value = false
|
||||
contextMenuEdge.value = null
|
||||
canvasContextMenuOpen.value = false
|
||||
}
|
||||
|
||||
function closeEdgeContextMenu() {
|
||||
@@ -42,6 +59,10 @@ export function useGraphNodeContextMenu() {
|
||||
contextMenuEdge.value = null
|
||||
}
|
||||
|
||||
function closeCanvasContextMenu() {
|
||||
canvasContextMenuOpen.value = false
|
||||
}
|
||||
|
||||
function resolveEdgeAtPointer(network, domEvent, getEdges) {
|
||||
let edgeId = null
|
||||
if (domEvent && typeof network.getPointer === 'function' && typeof network.getEdgeAt === 'function') {
|
||||
@@ -82,7 +103,7 @@ export function useGraphNodeContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
closeContextMenu()
|
||||
openCanvasContextMenu(domEvent)
|
||||
}
|
||||
|
||||
network.on('oncontext', onContext)
|
||||
@@ -98,10 +119,15 @@ export function useGraphNodeContextMenu() {
|
||||
contextMenuEdge,
|
||||
edgeContextMenuX,
|
||||
edgeContextMenuY,
|
||||
canvasContextMenuOpen,
|
||||
canvasContextMenuX,
|
||||
canvasContextMenuY,
|
||||
openContextMenu,
|
||||
openEdgeContextMenu,
|
||||
openCanvasContextMenu,
|
||||
closeContextMenu,
|
||||
closeEdgeContextMenu,
|
||||
closeCanvasContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ export const remoteNetworkMapMembershipRepository = {
|
||||
return data
|
||||
},
|
||||
async create(mapId, payload) {
|
||||
const { data } = await api.post(`/network-maps/${mapId}/memberships/`, payload)
|
||||
const { data } = await api.post(`/network-maps/${mapId}/memberships/`, {
|
||||
map: mapId,
|
||||
...payload,
|
||||
})
|
||||
return data
|
||||
},
|
||||
async update(mapId, id, payload) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
radiusRatioFromInvolvement,
|
||||
involvementFromRadiusRatio,
|
||||
} from '../../domain/conflictology'
|
||||
import { hasStoredMapPlacement } from './positioning'
|
||||
|
||||
export { CONFLICT_CENTER_NODE_ID }
|
||||
|
||||
@@ -23,12 +24,14 @@ export function computeConflictPositions(nodes, layout) {
|
||||
}
|
||||
|
||||
export function conflictNodeXY(node, layout, posById) {
|
||||
const ratio = Number(node.map_radius_ratio)
|
||||
const storedAngle = Number(node.map_angle)
|
||||
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
|
||||
const safeRatio = Math.max(0, Math.min(1, ratio))
|
||||
const r = safeRatio * layout.rOuter
|
||||
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
|
||||
if (hasStoredMapPlacement(node)) {
|
||||
const ratio = Number(node.map_radius_ratio)
|
||||
const storedAngle = Number(node.map_angle)
|
||||
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
|
||||
const safeRatio = Math.max(0, Math.min(1, ratio))
|
||||
const r = safeRatio * layout.rOuter
|
||||
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
|
||||
}
|
||||
}
|
||||
const p = posById.get(node.id)
|
||||
if (p) return p
|
||||
|
||||
@@ -53,6 +53,12 @@ export function normalizedCircle(n, geometry = defaultGeometry()) {
|
||||
return circleKeys[Math.floor(circleKeys.length / 2)] || circleKeys[0] || 'productivity'
|
||||
}
|
||||
|
||||
export function hasStoredMapPlacement(node) {
|
||||
const angle = node?.map_angle
|
||||
const ratio = node?.map_radius_ratio
|
||||
return angle != null && ratio != null && angle !== '' && ratio !== ''
|
||||
}
|
||||
|
||||
export function computePolarPositions(rawNodes, layout, geometry = defaultGeometry()) {
|
||||
const { sectorKeys } = geometry
|
||||
const groups = new Map()
|
||||
@@ -87,12 +93,14 @@ export function computePolarPositions(rawNodes, layout, geometry = defaultGeomet
|
||||
|
||||
export function nodeXY(n, layout, posById, geometry = defaultGeometry()) {
|
||||
const { sectorKeys } = geometry
|
||||
const ratio = Number(n.map_radius_ratio)
|
||||
const storedAngle = Number(n.map_angle)
|
||||
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
|
||||
const safeRatio = Math.max(0, Math.min(1, ratio))
|
||||
const r = safeRatio * layout.rOuter
|
||||
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
|
||||
if (hasStoredMapPlacement(n)) {
|
||||
const ratio = Number(n.map_radius_ratio)
|
||||
const storedAngle = Number(n.map_angle)
|
||||
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
|
||||
const safeRatio = Math.max(0, Math.min(1, ratio))
|
||||
const r = safeRatio * layout.rOuter
|
||||
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
|
||||
}
|
||||
}
|
||||
const p = posById.get(n.id)
|
||||
if (p) return p
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
sphereByAngle,
|
||||
ringByRadius,
|
||||
nodeXY,
|
||||
computePolarPositions,
|
||||
defaultGeometry,
|
||||
} from './positioning'
|
||||
|
||||
@@ -40,6 +41,19 @@ describe('map positioning', () => {
|
||||
expect(p.y).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('ignores null persisted coordinates and spreads nodes in sector', () => {
|
||||
const layout = { rInner: 10, rMid: 20, rOuter: 100, cx: 0, cy: 0 }
|
||||
const nodes = [
|
||||
{ id: 1, life_sphere: 'other', network_circle: 'productivity', map_angle: null, map_radius_ratio: null },
|
||||
{ id: 2, life_sphere: 'other', network_circle: 'productivity', map_angle: null, map_radius_ratio: null },
|
||||
]
|
||||
const posById = computePolarPositions(nodes, layout)
|
||||
const p1 = nodeXY(nodes[0], layout, posById)
|
||||
const p2 = nodeXY(nodes[1], layout, posById)
|
||||
expect(p1.x).not.toBeCloseTo(p2.x)
|
||||
expect(Math.hypot(p1.x, p1.y)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('defaultGeometry matches SPHERE_ORDER', () => {
|
||||
expect(defaultGeometry().sectorKeys).toEqual(SPHERE_ORDER)
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="graph-area" ref="graphArea">
|
||||
<div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
|
||||
<div class="graph-chrome-bar">
|
||||
<button
|
||||
type="button"
|
||||
@@ -77,7 +77,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card">
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card" @contextmenu.prevent="onGraphAreaContextMenu">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="5" cy="12" r="3"/><circle cx="19" cy="5" r="3"/><circle cx="19" cy="19" r="3"/>
|
||||
</svg>
|
||||
@@ -139,6 +139,20 @@
|
||||
@edit="openEditRelation"
|
||||
/>
|
||||
|
||||
<GraphCanvasContextMenu
|
||||
:open="canvasContextMenuOpen"
|
||||
:x="canvasContextMenuX"
|
||||
:y="canvasContextMenuY"
|
||||
@close="closeContextMenu"
|
||||
@create-contact="openCreateContact"
|
||||
/>
|
||||
|
||||
<CreateContactModal
|
||||
:open="createContactOpen"
|
||||
@close="createContactOpen = false"
|
||||
@created="onContactCreated"
|
||||
/>
|
||||
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
@@ -177,8 +191,11 @@ import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
||||
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
||||
import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
|
||||
import CreateContactModal from '../components/CreateContactModal.vue'
|
||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { getGraphToolbarActions } from '../core/pluginRegistry'
|
||||
|
||||
let themeObserver = null
|
||||
@@ -193,16 +210,44 @@ const {
|
||||
contextMenuEdge,
|
||||
edgeContextMenuX,
|
||||
edgeContextMenuY,
|
||||
canvasContextMenuOpen,
|
||||
canvasContextMenuX,
|
||||
canvasContextMenuY,
|
||||
openCanvasContextMenu,
|
||||
closeContextMenu,
|
||||
closeEdgeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
} = useGraphNodeContextMenu()
|
||||
|
||||
const createContactOpen = ref(false)
|
||||
|
||||
function openNodeInfo(node) {
|
||||
selectedNode.value = node || null
|
||||
}
|
||||
|
||||
function openCreateContact() {
|
||||
closeContextMenu()
|
||||
createContactOpen.value = true
|
||||
}
|
||||
|
||||
function onGraphAreaContextMenu(event) {
|
||||
if (nodes.value.length > 0) return
|
||||
openCanvasContextMenu(event)
|
||||
}
|
||||
|
||||
async function onContactCreated(data, mapIds, pluginPayload) {
|
||||
const created = await store.createContact(data)
|
||||
if (mapIds?.length) {
|
||||
await mapsStore.setContactMapMemberships(created.id, mapIds)
|
||||
}
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(created.id, pluginPayload)
|
||||
createContactOpen.value = false
|
||||
await ensureGraphReady({ showSpinner: false })
|
||||
}
|
||||
|
||||
const store = useContactsStore()
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const router = useRouter()
|
||||
const graphToolbarActions = getGraphToolbarActions()
|
||||
const graphArea = ref(null)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -14,12 +15,16 @@ const mapsStore = useNetworkMapsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await mapsStore.fetchMaps()
|
||||
const id = mapsStore.activeMapId || mapsStore.maps[0]?.id
|
||||
if (id) {
|
||||
router.replace({ name: 'NetworkMap', params: { mapId: id } })
|
||||
} else {
|
||||
router.replace({ name: 'Contacts' })
|
||||
const existing = mapsStore.maps.find(
|
||||
(m) => String(m.id) === String(mapsStore.activeMapId),
|
||||
) || mapsStore.maps[0]
|
||||
|
||||
const map = existing || await ensureDefaultNetworkMap()
|
||||
if (!existing) {
|
||||
await mapsStore.fetchMaps()
|
||||
}
|
||||
mapsStore.setActiveMapId(map.id)
|
||||
router.replace({ name: 'NetworkMap', params: { mapId: map.id } })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@create="openCreateMap"
|
||||
@manage="openEditMap"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="showAddContact = true">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="openAddContact">
|
||||
+ Участник
|
||||
</button>
|
||||
</template>
|
||||
@@ -42,15 +42,18 @@
|
||||
|
||||
</NetworkMapTopPanel>
|
||||
|
||||
<div class="network-map-body">
|
||||
<div class="network-map-body" @contextmenu.prevent="onMapBodyContextMenu">
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card">
|
||||
<p>
|
||||
На карте «{{ activeMap?.name || 'сети' }}» никого нет.
|
||||
<button type="button" class="btn btn-link" @click="showAddContact = true">Добавьте участников</button>
|
||||
из общего списка контактов или создайте новых в
|
||||
<RouterLink to="/contacts">карточках контактов</RouterLink>.
|
||||
</p>
|
||||
<div class="empty-state-actions">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="openAddContact">
|
||||
+ Добавить участника
|
||||
</button>
|
||||
<span class="text-muted">или создайте контакт в <RouterLink to="/contacts">карточках контактов</RouterLink></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="map-stack" ref="mapStack">
|
||||
<div id="network-map-container" ref="graphContainer" class="map-vis"></div>
|
||||
@@ -112,6 +115,21 @@
|
||||
@edit="openEditRelation"
|
||||
/>
|
||||
|
||||
<GraphCanvasContextMenu
|
||||
:open="canvasContextMenuOpen"
|
||||
:x="canvasContextMenuX"
|
||||
:y="canvasContextMenuY"
|
||||
@close="closeContextMenu"
|
||||
@create-contact="openCreateContact"
|
||||
/>
|
||||
|
||||
<CreateContactModal
|
||||
:open="createContactOpen"
|
||||
:initial-map-ids="createContactMapIds"
|
||||
@close="createContactOpen = false"
|
||||
@created="onContactCreated"
|
||||
/>
|
||||
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
@@ -143,10 +161,9 @@
|
||||
|
||||
<AddContactToMapModal
|
||||
:open="showAddContact"
|
||||
:contacts="store.contacts"
|
||||
:member-contact-ids="memberContactIds"
|
||||
:on-add="onAddContactToMap"
|
||||
@close="showAddContact = false"
|
||||
@add="onAddContactToMap"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -174,6 +191,7 @@ import {
|
||||
involvementNodeSize,
|
||||
} from '../domain/conflictology'
|
||||
import { fetchGraphBundle } from '../composables/useGraphData'
|
||||
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
|
||||
import { edgeFromRelation } from '../application/usecases/graph'
|
||||
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
||||
import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue'
|
||||
@@ -182,6 +200,8 @@ import AddContactToMapModal from '../components/AddContactToMapModal.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
||||
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
||||
import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
|
||||
import CreateContactModal from '../components/CreateContactModal.vue'
|
||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
@@ -200,16 +220,46 @@ const {
|
||||
contextMenuEdge,
|
||||
edgeContextMenuX,
|
||||
edgeContextMenuY,
|
||||
canvasContextMenuOpen,
|
||||
canvasContextMenuX,
|
||||
canvasContextMenuY,
|
||||
openCanvasContextMenu,
|
||||
closeContextMenu,
|
||||
closeEdgeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
} = useGraphNodeContextMenu()
|
||||
|
||||
const createContactOpen = ref(false)
|
||||
|
||||
function openNodeInfo(node) {
|
||||
selectedNode.value = node || null
|
||||
selectedInvolvement.value = Number(node?.conflict_involvement) || 3
|
||||
}
|
||||
|
||||
function openCreateContact() {
|
||||
closeContextMenu()
|
||||
createContactOpen.value = true
|
||||
}
|
||||
|
||||
function onMapBodyContextMenu(event) {
|
||||
if (loading.value || nodes.value.length > 0) return
|
||||
openCanvasContextMenu(event)
|
||||
}
|
||||
|
||||
async function onContactCreated(data, mapIds, pluginPayload) {
|
||||
const created = await store.createContact(data)
|
||||
const targetMapIds = mapIds?.length
|
||||
? mapIds
|
||||
: (mapId.value ? [String(mapId.value)] : [])
|
||||
if (targetMapIds.length) {
|
||||
await mapsStore.setContactMapMemberships(created.id, targetMapIds)
|
||||
}
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(created.id, pluginPayload)
|
||||
createContactOpen.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
async function saveInvolvement() {
|
||||
const node = selectedNode.value
|
||||
if (!node?.membership_id) return
|
||||
@@ -334,6 +384,9 @@ const mapsStore = useNetworkMapsStore()
|
||||
const typesStore = useNetworkMapTypesStore()
|
||||
|
||||
const mapId = computed(() => String(route.params.mapId || ''))
|
||||
const createContactMapIds = computed(() => (
|
||||
mapId.value ? [String(mapId.value)] : []
|
||||
))
|
||||
const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null)
|
||||
const activeMapType = computed(() => {
|
||||
const typeId = activeMap.value?.mapTypeId
|
||||
@@ -691,7 +744,12 @@ function buildVisNodes() {
|
||||
}
|
||||
|
||||
function initNetwork() {
|
||||
if (!graphContainer.value) return
|
||||
if (!graphContainer.value) {
|
||||
if (initRetryCount >= INIT_RETRY_MAX) return
|
||||
initRetryCount += 1
|
||||
initRetryTimer = setTimeout(initNetwork, 80)
|
||||
return
|
||||
}
|
||||
measureLayout()
|
||||
const el = graphContainer.value
|
||||
let { w, h } = layout.value
|
||||
@@ -877,8 +935,9 @@ async function load() {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
await store.fetchContacts()
|
||||
if (nodes.value.length > 0) {
|
||||
await store.fetchContacts()
|
||||
initRetryCount = 0
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
initNetwork()
|
||||
@@ -890,6 +949,10 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openAddContact() {
|
||||
showAddContact.value = true
|
||||
}
|
||||
|
||||
function switchMap(nextId) {
|
||||
if (!nextId || String(nextId) === mapId.value) return
|
||||
router.push({ name: 'NetworkMap', params: { mapId: nextId } })
|
||||
@@ -930,8 +993,13 @@ async function onMapDelete() {
|
||||
closeMapForm()
|
||||
if (String(mapId.value) === String(deletingId)) {
|
||||
const nextId = mapsStore.maps[0]?.id
|
||||
if (nextId) router.replace({ name: 'NetworkMap', params: { mapId: nextId } })
|
||||
else router.replace({ name: 'Contacts' })
|
||||
if (nextId) {
|
||||
router.replace({ name: 'NetworkMap', params: { mapId: nextId } })
|
||||
return
|
||||
}
|
||||
const created = await ensureDefaultNetworkMap()
|
||||
await mapsStore.fetchMaps()
|
||||
router.replace({ name: 'NetworkMap', params: { mapId: created.id } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,6 +1009,13 @@ async function onAddContactToMap(contactId) {
|
||||
await load()
|
||||
}
|
||||
|
||||
watch(graphContainer, (el) => {
|
||||
if (el && nodes.value.length > 0 && !network.value) {
|
||||
initRetryCount = 0
|
||||
initNetwork()
|
||||
}
|
||||
})
|
||||
|
||||
watch(mapId, async (next, prev) => {
|
||||
if (!next || next === prev) return
|
||||
network.value?.destroy()
|
||||
@@ -1039,4 +1114,11 @@ onUnmounted(() => {
|
||||
.legend-tension { color: #f1c40f; }
|
||||
.legend-alliance { color: #2ecc71; }
|
||||
.legend-neutral { color: #95a5a6; }
|
||||
.empty-state-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user