Add multiple network maps, graph UX improvements, and full backup import.
Support per-map contact membership with scoped graph views, relation editing on edges, layout caching, and automatic detection of full JSON backups so contacts and relations import together. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def migrate_contact_map_data(apps, schema_editor):
|
||||
Contact = apps.get_model('contacts', 'Contact')
|
||||
NetworkMap = apps.get_model('contacts', 'NetworkMap')
|
||||
NetworkMapMembership = apps.get_model('contacts', 'NetworkMapMembership')
|
||||
|
||||
default_map, _ = NetworkMap.objects.get_or_create(
|
||||
name='Основная карта',
|
||||
defaults={'description': 'Мигрировано из прежней единой карты сети'},
|
||||
)
|
||||
|
||||
for contact in Contact.objects.filter(include_on_network_map=True):
|
||||
NetworkMapMembership.objects.create(
|
||||
map=default_map,
|
||||
contact=contact,
|
||||
life_sphere=contact.life_sphere,
|
||||
network_circle=contact.network_circle,
|
||||
importance=contact.importance,
|
||||
map_angle=contact.map_angle,
|
||||
map_radius_ratio=contact.map_radius_ratio,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0005_contact_map_angle_contact_map_radius_ratio'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NetworkMap',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='Название')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Карта сети',
|
||||
'verbose_name_plural': 'Карты сети',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NetworkMapMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('life_sphere', models.CharField(
|
||||
choices=[
|
||||
('work', 'Работа'), ('study', 'Учёба'), ('hobby', 'Хобби'),
|
||||
('family', 'Семья'), ('health', 'Здоровье'), ('other', 'Другое'),
|
||||
],
|
||||
default='other',
|
||||
max_length=32,
|
||||
verbose_name='Сфера жизни',
|
||||
)),
|
||||
('network_circle', models.CharField(
|
||||
choices=[
|
||||
('support', 'Круг поддержки'),
|
||||
('productivity', 'Круг продуктивности'),
|
||||
('development', 'Круг развития'),
|
||||
],
|
||||
default='productivity',
|
||||
max_length=32,
|
||||
verbose_name='Круг сети',
|
||||
)),
|
||||
('importance', models.PositiveSmallIntegerField(
|
||||
default=3,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(5),
|
||||
],
|
||||
verbose_name='Важность (1–5)',
|
||||
)),
|
||||
('map_angle', models.FloatField(blank=True, null=True, verbose_name='Угол позиции на карте')),
|
||||
('map_radius_ratio', models.FloatField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(1),
|
||||
],
|
||||
verbose_name='Радиус позиции на карте (доля)',
|
||||
)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('contact', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='map_memberships',
|
||||
to='contacts.contact',
|
||||
verbose_name='Контакт',
|
||||
)),
|
||||
('map', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='memberships',
|
||||
to='contacts.networkmap',
|
||||
verbose_name='Карта',
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Участие на карте',
|
||||
'verbose_name_plural': 'Участия на картах',
|
||||
'unique_together': {('map', 'contact')},
|
||||
},
|
||||
),
|
||||
migrations.RunPython(migrate_contact_map_data, migrations.RunPython.noop),
|
||||
migrations.RemoveField(model_name='contact', name='include_on_network_map'),
|
||||
migrations.RemoveField(model_name='contact', name='life_sphere'),
|
||||
migrations.RemoveField(model_name='contact', name='network_circle'),
|
||||
migrations.RemoveField(model_name='contact', name='importance'),
|
||||
migrations.RemoveField(model_name='contact', name='map_angle'),
|
||||
migrations.RemoveField(model_name='contact', name='map_radius_ratio'),
|
||||
]
|
||||
@@ -19,6 +19,7 @@ NETWORK_CIRCLES = [
|
||||
|
||||
INTERACTION_INTENSITY = [
|
||||
('intense', 'Интенсивные контакты'),
|
||||
('periodic', 'Периодические контакты'),
|
||||
('sparse', 'Редкие контакты'),
|
||||
]
|
||||
|
||||
@@ -32,6 +33,50 @@ class Contact(models.Model):
|
||||
organization = models.CharField(max_length=255, blank=True, verbose_name='Организация')
|
||||
position = models.CharField(max_length=255, blank=True, verbose_name='Должность')
|
||||
notes = models.TextField(blank=True, verbose_name='Заметки')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = 'Контакт'
|
||||
verbose_name_plural = 'Контакты'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class NetworkMap(models.Model):
|
||||
"""Карта сети — отдельный контекст для визуализации подмножества контактов."""
|
||||
|
||||
name = models.CharField(max_length=255, verbose_name='Название')
|
||||
description = models.TextField(blank=True, verbose_name='Описание')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = 'Карта сети'
|
||||
verbose_name_plural = 'Карты сети'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class NetworkMapMembership(models.Model):
|
||||
"""Участие контакта на конкретной карте сети."""
|
||||
|
||||
map = models.ForeignKey(
|
||||
NetworkMap,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='memberships',
|
||||
verbose_name='Карта',
|
||||
)
|
||||
contact = models.ForeignKey(
|
||||
Contact,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='map_memberships',
|
||||
verbose_name='Контакт',
|
||||
)
|
||||
life_sphere = models.CharField(
|
||||
max_length=32,
|
||||
choices=LIFE_SPHERES,
|
||||
@@ -49,10 +94,6 @@ class Contact(models.Model):
|
||||
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
||||
verbose_name='Важность (1–5)',
|
||||
)
|
||||
include_on_network_map = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Показывать на карте сети',
|
||||
)
|
||||
map_angle = models.FloatField(null=True, blank=True, verbose_name='Угол позиции на карте')
|
||||
map_radius_ratio = models.FloatField(
|
||||
null=True,
|
||||
@@ -64,12 +105,12 @@ class Contact(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = 'Контакт'
|
||||
verbose_name_plural = 'Контакты'
|
||||
unique_together = ('map', 'contact')
|
||||
verbose_name = 'Участие на карте'
|
||||
verbose_name_plural = 'Участия на картах'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
return f'{self.contact} на {self.map}'
|
||||
|
||||
|
||||
RELATION_TYPES = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Contact, Relation
|
||||
from .models import Contact, Relation, NetworkMap, NetworkMapMembership
|
||||
|
||||
|
||||
class ContactSerializer(serializers.ModelSerializer):
|
||||
@@ -10,9 +10,6 @@ class ContactSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'name', 'email', 'phone',
|
||||
'organization', 'position', 'notes',
|
||||
'life_sphere', 'network_circle', 'importance',
|
||||
'include_on_network_map',
|
||||
'map_angle', 'map_radius_ratio',
|
||||
'created_at', 'updated_at', 'relations_count',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'relations_count']
|
||||
@@ -46,6 +43,36 @@ class RelationSerializer(serializers.ModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class NetworkMapSerializer(serializers.ModelSerializer):
|
||||
memberships_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = NetworkMap
|
||||
fields = [
|
||||
'id', 'name', 'description',
|
||||
'created_at', 'updated_at', 'memberships_count',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'memberships_count']
|
||||
|
||||
def get_memberships_count(self, obj):
|
||||
return obj.memberships.count()
|
||||
|
||||
|
||||
class NetworkMapMembershipSerializer(serializers.ModelSerializer):
|
||||
contact_name = serializers.CharField(source='contact.name', read_only=True)
|
||||
map_name = serializers.CharField(source='map.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = NetworkMapMembership
|
||||
fields = [
|
||||
'id', 'map', 'map_name', 'contact', 'contact_name',
|
||||
'life_sphere', 'network_circle', 'importance',
|
||||
'map_angle', 'map_radius_ratio',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'contact_name', 'map_name']
|
||||
|
||||
|
||||
class GraphSerializer(serializers.Serializer):
|
||||
"""Граф для vis.js: nodes + edges."""
|
||||
|
||||
@@ -60,9 +87,6 @@ class GraphSerializer(serializers.Serializer):
|
||||
'label': c.name,
|
||||
'title': f'{c.organization}\n{c.position}'.strip() or c.name,
|
||||
'group': c.organization or 'default',
|
||||
'life_sphere': c.life_sphere,
|
||||
'network_circle': c.network_circle,
|
||||
'importance': c.importance,
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
|
||||
@@ -5,9 +5,25 @@ from . import views
|
||||
router = DefaultRouter()
|
||||
router.register('contacts', views.ContactViewSet)
|
||||
router.register('relations', views.RelationViewSet)
|
||||
router.register('network-maps', views.NetworkMapViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path(
|
||||
'network-maps/<int:map_pk>/memberships/',
|
||||
views.NetworkMapMembershipViewSet.as_view({'get': 'list', 'post': 'create'}),
|
||||
name='network-map-memberships-list',
|
||||
),
|
||||
path(
|
||||
'network-maps/<int:map_pk>/memberships/<int:pk>/',
|
||||
views.NetworkMapMembershipViewSet.as_view({
|
||||
'get': 'retrieve',
|
||||
'patch': 'partial_update',
|
||||
'put': 'update',
|
||||
'delete': 'destroy',
|
||||
}),
|
||||
name='network-map-memberships-detail',
|
||||
),
|
||||
path('graph/', views.graph_data),
|
||||
path('network-map-graph/', views.network_map_graph),
|
||||
path('relation-types/', views.relation_types),
|
||||
|
||||
+72
-46
@@ -3,18 +3,25 @@ import io
|
||||
import json
|
||||
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import api_view, action
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import (
|
||||
Contact,
|
||||
Relation,
|
||||
NetworkMap,
|
||||
NetworkMapMembership,
|
||||
RELATION_TYPES,
|
||||
LIFE_SPHERES,
|
||||
NETWORK_CIRCLES,
|
||||
INTERACTION_INTENSITY,
|
||||
)
|
||||
from .serializers import ContactSerializer, RelationSerializer, GraphSerializer
|
||||
from .serializers import (
|
||||
ContactSerializer,
|
||||
RelationSerializer,
|
||||
NetworkMapSerializer,
|
||||
NetworkMapMembershipSerializer,
|
||||
)
|
||||
|
||||
|
||||
class ContactViewSet(viewsets.ModelViewSet):
|
||||
@@ -34,6 +41,53 @@ class RelationViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = RelationSerializer
|
||||
|
||||
|
||||
class NetworkMapViewSet(viewsets.ModelViewSet):
|
||||
queryset = NetworkMap.objects.all()
|
||||
serializer_class = NetworkMapSerializer
|
||||
|
||||
|
||||
class NetworkMapMembershipViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = NetworkMapMembershipSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
map_id = self.kwargs.get('map_pk')
|
||||
return NetworkMapMembership.objects.filter(
|
||||
map_id=map_id
|
||||
).select_related('contact', 'map')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
map_id = self.kwargs.get('map_pk')
|
||||
serializer.save(map_id=map_id)
|
||||
|
||||
|
||||
def _node_from_membership(membership):
|
||||
contact = membership.contact
|
||||
return {
|
||||
'id': contact.id,
|
||||
'label': contact.name,
|
||||
'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])),
|
||||
'group': contact.organization or 'default',
|
||||
'life_sphere': membership.life_sphere,
|
||||
'network_circle': membership.network_circle,
|
||||
'importance': membership.importance,
|
||||
'map_angle': membership.map_angle,
|
||||
'map_radius_ratio': membership.map_radius_ratio,
|
||||
'membership_id': membership.id,
|
||||
}
|
||||
|
||||
|
||||
def _edge_from_relation(r):
|
||||
return {
|
||||
'id': r.id,
|
||||
'from': r.source_id,
|
||||
'to': r.target_id,
|
||||
'label': r.get_relation_type_display(),
|
||||
'title': r.description or r.get_relation_type_display(),
|
||||
'relation_type': r.relation_type,
|
||||
'interaction_intensity': r.interaction_intensity,
|
||||
}
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def graph_data(request):
|
||||
"""Возвращает граф: nodes + edges для vis.js."""
|
||||
@@ -44,62 +98,34 @@ def graph_data(request):
|
||||
'label': c.name,
|
||||
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
|
||||
'group': c.organization or '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,
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
relations = Relation.objects.select_related('source', 'target').all()
|
||||
edges = [
|
||||
{
|
||||
'id': r.id,
|
||||
'from': r.source_id,
|
||||
'to': r.target_id,
|
||||
'label': r.get_relation_type_display(),
|
||||
'title': r.description or r.get_relation_type_display(),
|
||||
'relation_type': r.relation_type,
|
||||
'interaction_intensity': r.interaction_intensity,
|
||||
}
|
||||
for r in relations
|
||||
]
|
||||
edges = [_edge_from_relation(r) for r in relations]
|
||||
return Response({'nodes': nodes, 'edges': edges})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def network_map_graph(request):
|
||||
"""Граф только для карты сети: контакты с include_on_network_map и связи между ними."""
|
||||
contacts = list(
|
||||
Contact.objects.filter(include_on_network_map=True).order_by('name')
|
||||
"""Граф для конкретной карты сети: участники и связи между ними."""
|
||||
map_id = request.query_params.get('map_id')
|
||||
if not map_id:
|
||||
default_map = NetworkMap.objects.order_by('id').first()
|
||||
if not default_map:
|
||||
return Response({'nodes': [], 'edges': []})
|
||||
map_id = default_map.id
|
||||
|
||||
memberships = list(
|
||||
NetworkMapMembership.objects.filter(map_id=map_id)
|
||||
.select_related('contact')
|
||||
.order_by('contact__name')
|
||||
)
|
||||
allowed_ids = {c.id for c in contacts}
|
||||
nodes = [
|
||||
{
|
||||
'id': c.id,
|
||||
'label': c.name,
|
||||
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
|
||||
'group': c.organization or '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,
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
allowed_ids = {m.contact_id for m in memberships}
|
||||
nodes = [_node_from_membership(m) for m in memberships]
|
||||
relations = Relation.objects.select_related('source', 'target').all()
|
||||
edges = [
|
||||
{
|
||||
'id': r.id,
|
||||
'from': r.source_id,
|
||||
'to': r.target_id,
|
||||
'label': r.get_relation_type_display(),
|
||||
'title': r.description or r.get_relation_type_display(),
|
||||
'relation_type': r.relation_type,
|
||||
'interaction_intensity': r.interaction_intensity,
|
||||
}
|
||||
_edge_from_relation(r)
|
||||
for r in relations
|
||||
if r.source_id in allowed_ids and r.target_id in allowed_ids
|
||||
]
|
||||
|
||||
@@ -73,7 +73,11 @@
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<keep-alive include="Graph,NetworkMap">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { listContacts } from './contacts'
|
||||
import { listRelations } from './relations'
|
||||
import { listMembershipsByMap } from './networkMaps'
|
||||
import { getRelationTypes, getNetworkMapChoices } from '../../infrastructure/repositories/repositoryFactory'
|
||||
|
||||
function nodeFromContact(c) {
|
||||
function nodeFromContactAndMembership(contact, membership) {
|
||||
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,
|
||||
id: contact.id,
|
||||
label: contact.name,
|
||||
title: [contact.organization, contact.position, contact.email].filter(Boolean).join('\n'),
|
||||
group: contact.organization || 'default',
|
||||
life_sphere: membership.life_sphere,
|
||||
network_circle: membership.network_circle,
|
||||
importance: membership.importance,
|
||||
map_angle: membership.map_angle,
|
||||
map_radius_ratio: membership.map_radius_ratio,
|
||||
membership_id: membership.id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,21 +30,59 @@ export function edgeFromRelation(r) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGraphBundle({ networkMapOnly = false } = {}) {
|
||||
export function buildGraphFromStore(contacts, relations, relationTypes) {
|
||||
return {
|
||||
nodes: contacts.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.name,
|
||||
title: [c.organization, c.position, c.email].filter(Boolean).join('\n'),
|
||||
group: c.organization || 'default',
|
||||
})),
|
||||
edges: relations.map(edgeFromRelation),
|
||||
relationTypes,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGraphBundle({ mapId = null } = {}) {
|
||||
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))
|
||||
if (!mapId) {
|
||||
return {
|
||||
nodes: contacts.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.name,
|
||||
title: [c.organization, c.position, c.email].filter(Boolean).join('\n'),
|
||||
group: c.organization || 'default',
|
||||
})),
|
||||
edges: relations.map(edgeFromRelation),
|
||||
relationTypes,
|
||||
}
|
||||
}
|
||||
|
||||
const memberships = await listMembershipsByMap(mapId)
|
||||
const contactById = new Map(contacts.map((c) => [String(c.id), c]))
|
||||
const allowedIds = new Set()
|
||||
|
||||
const nodes = memberships
|
||||
.map((membership) => {
|
||||
const contactId = String(membership.contactId || membership.contact)
|
||||
const contact = contactById.get(contactId)
|
||||
if (!contact) return null
|
||||
allowedIds.add(contactId)
|
||||
return nodeFromContactAndMembership(contact, membership)
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const scopedRelations = relations.filter(
|
||||
(r) => allowedIds.has(String(r.source)) && allowedIds.has(String(r.target))
|
||||
)
|
||||
|
||||
return {
|
||||
nodes: scopedContacts.map(nodeFromContact),
|
||||
nodes,
|
||||
edges: scopedRelations.map(edgeFromRelation),
|
||||
relationTypes,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { getGraphBundle } from './graph'
|
||||
|
||||
vi.mock('./contacts', () => ({
|
||||
listContacts: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./relations', () => ({
|
||||
listRelations: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./networkMaps', () => ({
|
||||
listMembershipsByMap: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../infrastructure/repositories/repositoryFactory', () => ({
|
||||
getRelationTypes: vi.fn(async () => [{ value: 'friend', label: 'Друг' }]),
|
||||
}))
|
||||
|
||||
import { listContacts } from './contacts'
|
||||
import { listRelations } from './relations'
|
||||
import { listMembershipsByMap } from './networkMaps'
|
||||
|
||||
describe('getGraphBundle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns full graph when mapId is not provided', async () => {
|
||||
listContacts.mockResolvedValue([
|
||||
{ id: '1', name: 'Анна', organization: 'Org' },
|
||||
{ id: '2', name: 'Борис' },
|
||||
])
|
||||
listRelations.mockResolvedValue([
|
||||
{ id: '10', source: '1', target: '2', relation_type: 'friend' },
|
||||
])
|
||||
|
||||
const bundle = await getGraphBundle()
|
||||
expect(bundle.nodes).toHaveLength(2)
|
||||
expect(bundle.edges).toHaveLength(1)
|
||||
expect(listMembershipsByMap).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scopes nodes and edges to map memberships', async () => {
|
||||
listContacts.mockResolvedValue([
|
||||
{ id: '1', name: 'Анна' },
|
||||
{ id: '2', name: 'Борис' },
|
||||
{ id: '3', name: 'Вера' },
|
||||
])
|
||||
listRelations.mockResolvedValue([
|
||||
{ id: '10', source: '1', target: '2', relation_type: 'friend' },
|
||||
{ id: '11', source: '2', target: '3', relation_type: 'colleague' },
|
||||
])
|
||||
listMembershipsByMap.mockResolvedValue([
|
||||
{
|
||||
id: 'm1',
|
||||
contactId: '1',
|
||||
life_sphere: 'work',
|
||||
network_circle: 'productivity',
|
||||
importance: 4,
|
||||
map_angle: 0.5,
|
||||
map_radius_ratio: 0.6,
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
contactId: '2',
|
||||
life_sphere: 'family',
|
||||
network_circle: 'support',
|
||||
importance: 3,
|
||||
map_angle: 1.2,
|
||||
map_radius_ratio: 0.4,
|
||||
},
|
||||
])
|
||||
|
||||
const bundle = await getGraphBundle({ mapId: 'map-1' })
|
||||
expect(listMembershipsByMap).toHaveBeenCalledWith('map-1')
|
||||
expect(bundle.nodes).toHaveLength(2)
|
||||
expect(bundle.nodes[0]).toMatchObject({
|
||||
id: '1',
|
||||
life_sphere: 'work',
|
||||
membership_id: 'm1',
|
||||
})
|
||||
expect(bundle.edges).toHaveLength(1)
|
||||
expect(bundle.edges[0].id).toBe('10')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { listContacts, createContact } from './contacts'
|
||||
import { listRelations } from './relations'
|
||||
import { localDb } from '../../infrastructure/db/localDb'
|
||||
import { localDb, DEFAULT_MAP_NAME } from '../../infrastructure/db/localDb'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
import { parseVcf } from '../../lib/import/vcard'
|
||||
import { serializeContactsExport } from '../../lib/export/contacts'
|
||||
|
||||
@@ -10,12 +11,20 @@ function isLikelyEmail(value) {
|
||||
|
||||
function normalizeRows(raw) {
|
||||
if (Array.isArray(raw)) return raw
|
||||
if (raw && Array.isArray(raw.contacts)) return raw.contacts
|
||||
if (raw && Array.isArray(raw.contacts) && !isLocalDataDump(raw)) return raw.contacts
|
||||
if (raw && Array.isArray(raw.results)) return raw.results
|
||||
if (raw && Array.isArray(raw.data)) return raw.data
|
||||
return []
|
||||
}
|
||||
|
||||
export function isEncryptedLocalDump(raw) {
|
||||
return Boolean(raw?.data && raw?.algorithm && raw?.iv && raw?.salt)
|
||||
}
|
||||
|
||||
export function isLocalDataDump(raw) {
|
||||
return Boolean(raw && Array.isArray(raw.contacts) && Array.isArray(raw.relations))
|
||||
}
|
||||
|
||||
function parseCsv(text) {
|
||||
const lines = text.split(/\r?\n/).filter(Boolean)
|
||||
if (!lines.length) return []
|
||||
@@ -59,7 +68,20 @@ export async function importContactsFromFile(file) {
|
||||
if (name.endsWith('.csv')) {
|
||||
rows = parseCsv(rawText)
|
||||
} else if (name.endsWith('.json')) {
|
||||
rows = normalizeRows(JSON.parse(rawText))
|
||||
const raw = JSON.parse(rawText)
|
||||
if (isLocalDataDump(raw) || isEncryptedLocalDump(raw)) {
|
||||
const summary = await importLocalDump(file)
|
||||
return {
|
||||
total: summary.importedContacts + summary.importedRelations,
|
||||
created: summary.importedContacts,
|
||||
importedRelations: summary.importedRelations,
|
||||
importedMaps: summary.importedMaps,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
isDump: true,
|
||||
}
|
||||
}
|
||||
rows = normalizeRows(raw)
|
||||
} else if (name.endsWith('.vcf') || name.endsWith('.vcard')) {
|
||||
rows = parseVcf(rawText)
|
||||
if (!rows.length) {
|
||||
@@ -147,12 +169,115 @@ async function deriveKey(passphrase, saltBytes) {
|
||||
)
|
||||
}
|
||||
|
||||
function stripLegacyContactFields(contact) {
|
||||
const next = { ...contact }
|
||||
delete next.include_on_network_map
|
||||
delete next.life_sphere
|
||||
delete next.network_circle
|
||||
delete next.importance
|
||||
delete next.map_angle
|
||||
delete next.map_radius_ratio
|
||||
delete next.relations_count
|
||||
delete next.created_at
|
||||
delete next.updated_at
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeDumpContact(contact) {
|
||||
const ts = contact.createdAt || contact.created_at || new Date().toISOString()
|
||||
return {
|
||||
...stripLegacyContactFields(contact),
|
||||
id: contact.id,
|
||||
createdAt: contact.createdAt || contact.created_at || ts,
|
||||
updatedAt: contact.updatedAt || contact.updated_at || ts,
|
||||
deletedAt: contact.deletedAt ?? contact.deleted_at ?? null,
|
||||
workspaceId: contact.workspaceId || 'personal',
|
||||
ownerId: contact.ownerId || 'local-user',
|
||||
version: contact.version ?? 1,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDumpRelation(relation) {
|
||||
const ts = relation.createdAt || relation.created_at || new Date().toISOString()
|
||||
return {
|
||||
id: relation.id,
|
||||
source: relation.source,
|
||||
target: relation.target,
|
||||
relation_type: relation.relation_type || 'acquaintance',
|
||||
description: relation.description || '',
|
||||
interaction_intensity: relation.interaction_intensity || 'intense',
|
||||
ownerId: relation.ownerId || 'local-user',
|
||||
workspaceId: relation.workspaceId || 'personal',
|
||||
version: relation.version ?? 1,
|
||||
createdAt: ts,
|
||||
updatedAt: relation.updatedAt || relation.updated_at || ts,
|
||||
deletedAt: relation.deletedAt ?? relation.deleted_at ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function migrateV1Dump(dump) {
|
||||
if ((dump.version || 1) >= 2) {
|
||||
return {
|
||||
...dump,
|
||||
networkMaps: dump.networkMaps || [],
|
||||
networkMapMemberships: dump.networkMapMemberships || [],
|
||||
contacts: (dump.contacts || []).map(stripLegacyContactFields),
|
||||
}
|
||||
}
|
||||
|
||||
const ts = new Date().toISOString()
|
||||
const mapId = generateId()
|
||||
const networkMaps = [{
|
||||
id: mapId,
|
||||
name: DEFAULT_MAP_NAME,
|
||||
description: 'Импортировано из бэкапа v1',
|
||||
workspaceId: 'personal',
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
deletedAt: null,
|
||||
}]
|
||||
const networkMapMemberships = []
|
||||
const contacts = (dump.contacts || []).map((contact) => {
|
||||
if (contact.include_on_network_map) {
|
||||
networkMapMemberships.push({
|
||||
id: generateId(),
|
||||
mapId,
|
||||
contactId: contact.id,
|
||||
life_sphere: contact.life_sphere || 'other',
|
||||
network_circle: contact.network_circle || 'productivity',
|
||||
importance: contact.importance ?? 3,
|
||||
map_angle: contact.map_angle ?? null,
|
||||
map_radius_ratio: contact.map_radius_ratio ?? null,
|
||||
workspaceId: 'personal',
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
deletedAt: null,
|
||||
})
|
||||
}
|
||||
return stripLegacyContactFields(contact)
|
||||
})
|
||||
|
||||
return {
|
||||
...dump,
|
||||
version: 2,
|
||||
contacts,
|
||||
relations: dump.relations || [],
|
||||
networkMaps,
|
||||
networkMapMemberships,
|
||||
changes: dump.changes || [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportLocalData({ passphrase = '' } = {}) {
|
||||
const payload = {
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
contacts: await listContacts(),
|
||||
relations: await listRelations(),
|
||||
networkMaps: await localDb.networkMaps.toArray(),
|
||||
networkMapMemberships: await localDb.networkMapMemberships.toArray(),
|
||||
changes: await localDb.changelog.toArray(),
|
||||
}
|
||||
|
||||
@@ -169,7 +294,7 @@ export async function exportLocalData({ passphrase = '' } = {}) {
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(payload))
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
|
||||
const wrapped = {
|
||||
version: 1,
|
||||
version: 2,
|
||||
algorithm: 'AES-GCM',
|
||||
kdf: 'PBKDF2-SHA256',
|
||||
iterations: 210000,
|
||||
@@ -194,25 +319,48 @@ async function decryptPayload(raw, passphrase) {
|
||||
|
||||
export async function importLocalDump(file, passphrase = '') {
|
||||
const raw = JSON.parse(await file.text())
|
||||
const dump = raw?.data ? await decryptPayload(raw, passphrase) : raw
|
||||
const dump = migrateV1Dump(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)
|
||||
await localDb.transaction(
|
||||
'rw',
|
||||
localDb.contacts,
|
||||
localDb.relations,
|
||||
localDb.networkMaps,
|
||||
localDb.networkMapMemberships,
|
||||
localDb.changelog,
|
||||
async () => {
|
||||
for (const contact of dump.contacts) {
|
||||
await localDb.contacts.put(normalizeDumpContact(contact))
|
||||
}
|
||||
for (const relation of dump.relations) {
|
||||
await localDb.relations.put(normalizeDumpRelation(relation))
|
||||
}
|
||||
if (Array.isArray(dump.networkMaps)) {
|
||||
for (const map of dump.networkMaps) {
|
||||
await localDb.networkMaps.put(map)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(dump.networkMapMemberships)) {
|
||||
for (const membership of dump.networkMapMemberships) {
|
||||
await localDb.networkMapMemberships.put(membership)
|
||||
}
|
||||
}
|
||||
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 }
|
||||
return {
|
||||
importedContacts: dump.contacts.length,
|
||||
importedRelations: dump.relations.length,
|
||||
importedMaps: dump.networkMaps?.length || 0,
|
||||
importedMemberships: dump.networkMapMemberships?.length || 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isLocalDataDump, isEncryptedLocalDump } from './importExport'
|
||||
|
||||
// Test migration logic via exported helper pattern - inline replicate of migrateV1Dump
|
||||
function stripLegacyContactFields(contact) {
|
||||
const next = { ...contact }
|
||||
delete next.include_on_network_map
|
||||
delete next.life_sphere
|
||||
delete next.network_circle
|
||||
delete next.importance
|
||||
delete next.map_angle
|
||||
delete next.map_radius_ratio
|
||||
return next
|
||||
}
|
||||
|
||||
function migrateV1Dump(dump) {
|
||||
if ((dump.version || 1) >= 2) {
|
||||
return {
|
||||
...dump,
|
||||
networkMaps: dump.networkMaps || [],
|
||||
networkMapMemberships: dump.networkMapMemberships || [],
|
||||
contacts: (dump.contacts || []).map(stripLegacyContactFields),
|
||||
}
|
||||
}
|
||||
|
||||
const mapId = 'default-map'
|
||||
const networkMaps = [{ id: mapId, name: 'Основная карта' }]
|
||||
const networkMapMemberships = []
|
||||
const contacts = (dump.contacts || []).map((contact) => {
|
||||
if (contact.include_on_network_map) {
|
||||
networkMapMemberships.push({
|
||||
id: `m-${contact.id}`,
|
||||
mapId,
|
||||
contactId: contact.id,
|
||||
life_sphere: contact.life_sphere || 'other',
|
||||
network_circle: contact.network_circle || 'productivity',
|
||||
importance: contact.importance ?? 3,
|
||||
})
|
||||
}
|
||||
return stripLegacyContactFields(contact)
|
||||
})
|
||||
|
||||
return {
|
||||
...dump,
|
||||
version: 2,
|
||||
contacts,
|
||||
networkMaps,
|
||||
networkMapMemberships,
|
||||
}
|
||||
}
|
||||
|
||||
describe('local dump detection', () => {
|
||||
it('detects full backup JSON', () => {
|
||||
expect(isLocalDataDump({ version: 2, contacts: [], relations: [] })).toBe(true)
|
||||
expect(isLocalDataDump({ contacts: [{ name: 'A' }] })).toBe(false)
|
||||
expect(isLocalDataDump([{ name: 'A' }])).toBe(false)
|
||||
})
|
||||
|
||||
it('detects encrypted backup wrapper', () => {
|
||||
expect(isEncryptedLocalDump({
|
||||
algorithm: 'AES-GCM',
|
||||
salt: 'abc',
|
||||
iv: 'def',
|
||||
data: 'ghi',
|
||||
})).toBe(true)
|
||||
expect(isEncryptedLocalDump({ contacts: [], relations: [] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('backup v1 migration', () => {
|
||||
it('creates default map and memberships from legacy contact fields', () => {
|
||||
const migrated = migrateV1Dump({
|
||||
version: 1,
|
||||
contacts: [
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'Иван',
|
||||
include_on_network_map: true,
|
||||
life_sphere: 'work',
|
||||
network_circle: 'support',
|
||||
importance: 5,
|
||||
},
|
||||
{ id: 'c2', name: 'Мария', include_on_network_map: false },
|
||||
],
|
||||
relations: [],
|
||||
})
|
||||
|
||||
expect(migrated.version).toBe(2)
|
||||
expect(migrated.networkMaps).toHaveLength(1)
|
||||
expect(migrated.networkMapMemberships).toHaveLength(1)
|
||||
expect(migrated.networkMapMemberships[0]).toMatchObject({
|
||||
contactId: 'c1',
|
||||
life_sphere: 'work',
|
||||
importance: 5,
|
||||
})
|
||||
expect(migrated.contacts[0].include_on_network_map).toBeUndefined()
|
||||
expect(migrated.contacts[1].life_sphere).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { appendChange } from '../../infrastructure/sync/changeLogRepository'
|
||||
import {
|
||||
getNetworkMapRepository,
|
||||
getNetworkMapMembershipRepository,
|
||||
} from '../../infrastructure/repositories/repositoryFactory'
|
||||
|
||||
const mapRepo = () => getNetworkMapRepository()
|
||||
const membershipRepo = () => getNetworkMapMembershipRepository()
|
||||
|
||||
export async function listNetworkMaps() {
|
||||
return mapRepo().list()
|
||||
}
|
||||
|
||||
export async function getNetworkMapById(id) {
|
||||
return mapRepo().getById(id)
|
||||
}
|
||||
|
||||
export async function createNetworkMap(payload) {
|
||||
const created = await mapRepo().create(payload)
|
||||
await appendChange({
|
||||
entityType: 'networkMap',
|
||||
entityId: created.id,
|
||||
op: 'created',
|
||||
payloadPatch: created,
|
||||
})
|
||||
return created
|
||||
}
|
||||
|
||||
export async function updateNetworkMap(id, payload) {
|
||||
const updated = await mapRepo().update(id, payload)
|
||||
await appendChange({
|
||||
entityType: 'networkMap',
|
||||
entityId: id,
|
||||
op: 'updated',
|
||||
payloadPatch: payload,
|
||||
})
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function deleteNetworkMap(id) {
|
||||
await mapRepo().remove(id)
|
||||
await appendChange({
|
||||
entityType: 'networkMap',
|
||||
entityId: id,
|
||||
op: 'deleted',
|
||||
payloadPatch: {},
|
||||
})
|
||||
}
|
||||
|
||||
export async function listMembershipsByMap(mapId) {
|
||||
return membershipRepo().listByMap(mapId)
|
||||
}
|
||||
|
||||
export async function listMembershipsByContact(contactId) {
|
||||
return membershipRepo().listByContact(contactId)
|
||||
}
|
||||
|
||||
export async function addContactToMap(mapId, contactId, extra = {}) {
|
||||
const created = await membershipRepo().create(mapId, {
|
||||
contact: contactId,
|
||||
...extra,
|
||||
})
|
||||
await appendChange({
|
||||
entityType: 'networkMapMembership',
|
||||
entityId: created.id,
|
||||
op: 'created',
|
||||
payloadPatch: created,
|
||||
})
|
||||
return created
|
||||
}
|
||||
|
||||
export async function updateMembership(mapId, membershipId, payload) {
|
||||
const updated = await membershipRepo().update(mapId, membershipId, payload)
|
||||
await appendChange({
|
||||
entityType: 'networkMapMembership',
|
||||
entityId: membershipId,
|
||||
op: 'updated',
|
||||
payloadPatch: payload,
|
||||
})
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function removeContactFromMap(mapId, membershipId) {
|
||||
await membershipRepo().remove(mapId, membershipId)
|
||||
await appendChange({
|
||||
entityType: 'networkMapMembership',
|
||||
entityId: membershipId,
|
||||
op: 'deleted',
|
||||
payloadPatch: {},
|
||||
})
|
||||
}
|
||||
|
||||
export async function setContactMapMemberships(contactId, mapIds = []) {
|
||||
const existing = await listMembershipsByContact(contactId)
|
||||
const existingMapIds = new Set(existing.map((m) => String(m.mapId || m.map)))
|
||||
const targetMapIds = new Set(mapIds.map(String))
|
||||
|
||||
for (const membership of existing) {
|
||||
const mid = String(membership.mapId || membership.map)
|
||||
if (!targetMapIds.has(mid)) {
|
||||
await removeContactFromMap(mid, membership.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const mapId of targetMapIds) {
|
||||
if (!existingMapIds.has(mapId)) {
|
||||
await addContactToMap(mapId, contactId)
|
||||
}
|
||||
}
|
||||
|
||||
return listMembershipsByContact(contactId)
|
||||
}
|
||||
@@ -27,3 +27,14 @@ export async function deleteRelation(id) {
|
||||
payloadPatch: {},
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateRelation(id, payload) {
|
||||
const relation = await relationRepo().update(id, payload)
|
||||
await appendChange({
|
||||
entityType: 'relation',
|
||||
entityId: id,
|
||||
op: 'updated',
|
||||
payloadPatch: payload,
|
||||
})
|
||||
return relation
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
contacts: { type: Array, default: () => [] },
|
||||
memberContactIds: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close', 'add'])
|
||||
|
||||
const selectedContactId = ref('')
|
||||
|
||||
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 }))
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) selectedContactId.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
function onAdd() {
|
||||
if (!selectedContactId.value) return
|
||||
emit('add', selectedContactId.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,39 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContactForm from './ContactForm.vue'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
default: {
|
||||
get: vi.fn(async () => ({
|
||||
data: {
|
||||
life_spheres: [{ value: 'work', label: 'Работа' }],
|
||||
network_circles: [{ value: 'support', label: 'Круг поддержки' }],
|
||||
},
|
||||
})),
|
||||
},
|
||||
vi.mock('../stores/networkMaps', () => ({
|
||||
useNetworkMapsStore: () => ({
|
||||
maps: [{ id: 'map-1', name: 'Основная карта' }],
|
||||
fetchMaps: vi.fn(async () => []),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../application/usecases/networkMaps', () => ({
|
||||
listMembershipsByContact: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
describe('ContactForm', () => {
|
||||
it('emits submit with normalized payload', async () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('emits submit with contact data and map ids', async () => {
|
||||
const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } })
|
||||
await wrapper.get('form').trigger('submit.prevent')
|
||||
const payload = wrapper.emitted('submit')[0][0]
|
||||
expect(payload.name).toBe('Иван')
|
||||
expect(payload.importance).toBeDefined()
|
||||
expect(payload.include_on_network_map).toBe(false)
|
||||
const [contactData, mapIds] = wrapper.emitted('submit')[0]
|
||||
expect(contactData.name).toBe('Иван')
|
||||
expect(contactData.include_on_network_map).toBeUndefined()
|
||||
expect(mapIds).toEqual([])
|
||||
})
|
||||
|
||||
it('shows delete button when editing existing contact', async () => {
|
||||
const wrapper = mount(ContactForm, {
|
||||
props: { initial: { id: '1', name: 'Иван' }, deletable: true },
|
||||
})
|
||||
const deleteBtn = wrapper.find('.btn-danger')
|
||||
expect(deleteBtn.exists()).toBe(true)
|
||||
await deleteBtn.trigger('click')
|
||||
expect(wrapper.emitted('delete')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,70 +24,65 @@
|
||||
<input v-model="form.position" class="form-control" placeholder="Директор" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-form-map-fields">
|
||||
<div class="form-group">
|
||||
<label>Сфера жизни</label>
|
||||
<SearchableSelect
|
||||
v-model="form.life_sphere"
|
||||
:options="lifeSpheres"
|
||||
placeholder="Сфера жизни..."
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label>Карты сети</label>
|
||||
<div v-if="!availableMaps.length" class="text-muted" style="font-size:13px;">
|
||||
Нет карт. Создайте карту на странице «Карта сети».
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Круг сети</label>
|
||||
<SearchableSelect
|
||||
v-model="form.network_circle"
|
||||
:options="networkCircles"
|
||||
placeholder="Круг сети..."
|
||||
/>
|
||||
<div v-else class="map-checkboxes">
|
||||
<label v-for="map in availableMaps" :key="map.id" class="checkbox-label">
|
||||
<input
|
||||
v-model="form.mapIds"
|
||||
type="checkbox"
|
||||
:value="String(map.id)"
|
||||
/>
|
||||
{{ map.name }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Важность (1–5)</label>
|
||||
<input v-model.number="form.importance" type="number" min="1" max="5" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group checkbox-row">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="form.include_on_network_map" type="checkbox" />
|
||||
Показывать на карте сети
|
||||
</label>
|
||||
<p class="checkbox-hint">На странице «Граф» контакт виден всегда; на «Карте сети» — только с этой отметкой.</p>
|
||||
<p class="checkbox-hint">На странице «Граф» контакт виден всегда; на выбранных картах — в соответствующих визуализациях.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Заметки</label>
|
||||
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<div class="contact-form-footer">
|
||||
<button
|
||||
v-if="showDelete"
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
<div class="contact-form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch, ref, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
import { reactive, watch, computed, onMounted } from 'vue'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { listMembershipsByContact } from '../application/usecases/networkMaps'
|
||||
|
||||
const FALLBACK_SPHERES = [
|
||||
{ value: 'work', label: 'Работа' },
|
||||
{ value: 'study', label: 'Учёба' },
|
||||
{ value: 'hobby', label: 'Хобби' },
|
||||
{ value: 'family', label: 'Семья' },
|
||||
{ value: 'health', label: 'Здоровье' },
|
||||
{ value: 'other', label: 'Другое' },
|
||||
]
|
||||
const FALLBACK_CIRCLES = [
|
||||
{ value: 'support', label: 'Круг поддержки' },
|
||||
{ value: 'productivity', label: 'Круг продуктивности' },
|
||||
{ value: 'development', label: 'Круг развития' },
|
||||
]
|
||||
const props = defineProps({
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
initialMapIds: { type: Array, default: null },
|
||||
deletable: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['submit', 'cancel', 'delete'])
|
||||
|
||||
const props = defineProps({ initial: { type: Object, default: () => ({}) } })
|
||||
const emit = defineEmits(['submit', 'cancel'])
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
|
||||
const lifeSpheres = ref([...FALLBACK_SPHERES])
|
||||
const networkCircles = ref([...FALLBACK_CIRCLES])
|
||||
const showDelete = computed(() => {
|
||||
if (props.deletable) return true
|
||||
const id = props.initial?.id
|
||||
return id !== undefined && id !== null && id !== ''
|
||||
})
|
||||
|
||||
const availableMaps = computed(() => mapsStore.maps)
|
||||
|
||||
const form = reactive({
|
||||
name: props.initial.name || '',
|
||||
@@ -96,13 +91,19 @@ const form = reactive({
|
||||
organization: props.initial.organization || '',
|
||||
position: props.initial.position || '',
|
||||
notes: props.initial.notes || '',
|
||||
life_sphere: props.initial.life_sphere || 'other',
|
||||
network_circle: props.initial.network_circle || 'productivity',
|
||||
importance: props.initial.importance ?? 3,
|
||||
include_on_network_map: Boolean(props.initial.include_on_network_map),
|
||||
mapIds: (props.initialMapIds || []).map(String),
|
||||
})
|
||||
|
||||
watch(() => props.initial, (v) => {
|
||||
async function loadMapIdsForContact(contactId) {
|
||||
if (!contactId) {
|
||||
form.mapIds = []
|
||||
return
|
||||
}
|
||||
const memberships = await listMembershipsByContact(contactId)
|
||||
form.mapIds = memberships.map((m) => String(m.mapId || m.map))
|
||||
}
|
||||
|
||||
watch(() => props.initial, async (v) => {
|
||||
if (!v || !Object.keys(v).length) {
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
@@ -111,10 +112,7 @@ watch(() => props.initial, (v) => {
|
||||
organization: '',
|
||||
position: '',
|
||||
notes: '',
|
||||
life_sphere: 'other',
|
||||
network_circle: 'productivity',
|
||||
importance: 3,
|
||||
include_on_network_map: false,
|
||||
mapIds: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -125,37 +123,32 @@ watch(() => props.initial, (v) => {
|
||||
organization: v.organization || '',
|
||||
position: v.position || '',
|
||||
notes: v.notes || '',
|
||||
life_sphere: v.life_sphere || 'other',
|
||||
network_circle: v.network_circle || 'productivity',
|
||||
importance: v.importance ?? 3,
|
||||
include_on_network_map: Boolean(v.include_on_network_map),
|
||||
})
|
||||
if (props.initialMapIds) {
|
||||
form.mapIds = props.initialMapIds.map(String)
|
||||
} else if (v.id) {
|
||||
await loadMapIdsForContact(v.id)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/network-map-choices/')
|
||||
if (data.life_spheres?.length) lifeSpheres.value = data.life_spheres
|
||||
if (data.network_circles?.length) networkCircles.value = data.network_circles
|
||||
} catch {
|
||||
/* оставляем FALLBACK_* */
|
||||
await mapsStore.fetchMaps()
|
||||
if (props.initial?.id && !props.initialMapIds) {
|
||||
await loadMapIdsForContact(props.initial.id)
|
||||
}
|
||||
})
|
||||
|
||||
function onSubmit() {
|
||||
emit('submit', { ...form })
|
||||
const { mapIds, ...contactData } = form
|
||||
emit('submit', contactData, mapIds)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contact-form-map-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
margin-top: 4px;
|
||||
.map-checkboxes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
@@ -170,4 +163,16 @@ function onSubmit() {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.contact-form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.contact-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,19 +20,11 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label>Тип связи</label>
|
||||
<SearchableSelect
|
||||
v-model="form.type"
|
||||
:options="relationTypes"
|
||||
placeholder="Тип связи..."
|
||||
/>
|
||||
<RelationTypeSelect v-model="form.type" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<SearchableSelect
|
||||
v-model="form.intensity"
|
||||
:options="interactionIntensities"
|
||||
placeholder="Интенсивность..."
|
||||
/>
|
||||
<InteractionIntensitySelect v-model="form.intensity" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание (необязательно)</label>
|
||||
@@ -52,8 +44,8 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
import { RELATION_TYPES, INTERACTION_INTENSITIES } from '../domain/networkChoices'
|
||||
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
|
||||
import RelationTypeSelect from './RelationTypeSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
@@ -68,9 +60,6 @@ const saving = ref(false)
|
||||
const error = ref('')
|
||||
const swapped = ref(false)
|
||||
|
||||
const relationTypes = RELATION_TYPES
|
||||
const interactionIntensities = INTERACTION_INTENSITIES
|
||||
|
||||
const form = ref({
|
||||
type: 'acquaintance',
|
||||
intensity: 'intense',
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="onCancel">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Редактировать связь</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="onCancel">✕</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-error">{{ error }}</div>
|
||||
|
||||
<p v-if="relation" class="text-muted relation-ends">
|
||||
<strong style="color:var(--text)">{{ sourceName }}</strong>
|
||||
<span class="relation-arrow">→</span>
|
||||
<strong style="color:var(--text)">{{ targetName }}</strong>
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Тип связи</label>
|
||||
<RelationTypeSelect v-model="form.type" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<InteractionIntensitySelect v-model="form.intensity" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание (необязательно)</label>
|
||||
<input v-model="form.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
|
||||
</div>
|
||||
|
||||
<div class="relation-edit-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
:disabled="saving || deleting"
|
||||
@click="onDelete"
|
||||
>
|
||||
{{ deleting ? 'Удаление...' : 'Удалить' }}
|
||||
</button>
|
||||
<div class="relation-edit-actions">
|
||||
<button class="btn btn-secondary" type="button" :disabled="saving || deleting" @click="onCancel">
|
||||
Отмена
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="saving || deleting" @click="submit">
|
||||
{{ saving ? 'Сохранение...' : 'Сохранить' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
|
||||
import RelationTypeSelect from './RelationTypeSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
relation: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'updated', 'deleted'])
|
||||
|
||||
const store = useContactsStore()
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
type: 'acquaintance',
|
||||
intensity: 'intense',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const sourceName = computed(() => props.relation?.source_name || '—')
|
||||
const targetName = computed(() => props.relation?.target_name || '—')
|
||||
|
||||
watch(
|
||||
() => [props.open, props.relation],
|
||||
() => {
|
||||
if (!props.open || !props.relation) return
|
||||
error.value = ''
|
||||
form.value = {
|
||||
type: props.relation.relation_type || 'acquaintance',
|
||||
intensity: props.relation.interaction_intensity || 'intense',
|
||||
description: props.relation.description || '',
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function onCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!props.relation?.id) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const updated = await store.updateRelation(props.relation.id, {
|
||||
relation_type: form.value.type,
|
||||
description: form.value.description,
|
||||
interaction_intensity: form.value.intensity,
|
||||
})
|
||||
emit('updated', updated)
|
||||
emit('close')
|
||||
} catch (e) {
|
||||
error.value = e?.message || String(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!props.relation?.id) return
|
||||
const label = `${sourceName.value} → ${targetName.value}`
|
||||
if (!window.confirm(`Удалить связь «${label}»?`)) return
|
||||
deleting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await store.deleteRelation(props.relation.id)
|
||||
emit('deleted', props.relation.id)
|
||||
emit('close')
|
||||
} catch (e) {
|
||||
error.value = e?.message || String(e)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.relation-ends {
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.relation-arrow {
|
||||
margin: 0 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.relation-edit-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.relation-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="graph-node-menu-overlay"
|
||||
@click="close"
|
||||
@contextmenu.prevent="close"
|
||||
/>
|
||||
<div
|
||||
v-if="open && edge"
|
||||
class="graph-node-menu"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
role="menu"
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<div class="graph-node-menu__title">{{ edgeTitle }}</div>
|
||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onEdit">
|
||||
Редактировать связь
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RELATION_TYPES } from '../domain/networkChoices'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
edge: { type: Object, default: null },
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'edit'])
|
||||
|
||||
const typeLabels = Object.fromEntries(RELATION_TYPES.map((r) => [r.value, r.label]))
|
||||
|
||||
const edgeTitle = computed(() => {
|
||||
if (!props.edge) return 'Связь'
|
||||
const type = typeLabels[props.edge.relation_type] || props.edge.relation_type || 'Связь'
|
||||
return type
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onEdit() {
|
||||
emit('edit', props.edge)
|
||||
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__title {
|
||||
padding: 6px 14px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 4px;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<select
|
||||
class="form-control"
|
||||
:value="modelValue"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { INTERACTION_INTENSITIES } from '../domain/networkChoices'
|
||||
|
||||
defineProps({
|
||||
modelValue: { type: String, default: 'intense' },
|
||||
options: { type: Array, default: () => INTERACTION_INTENSITIES },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function onChange(e) {
|
||||
emit('update:modelValue', e.target.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ isEdit ? 'Редактировать карту' : 'Новая карта сети' }}</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="form-group">
|
||||
<label>Название *</label>
|
||||
<input v-model="form.name" class="form-control" required placeholder="Например: Коллектив А" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="Цель карты, контекст..."
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
v-if="isEdit && deletable"
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
<div class="modal-footer-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
deletable: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['close', 'submit', 'delete'])
|
||||
|
||||
const isEdit = computed(() => Boolean(props.initial?.id))
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.open, props.initial],
|
||||
() => {
|
||||
if (!props.open) return
|
||||
form.name = props.initial?.name || ''
|
||||
form.description = props.initial?.description || ''
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
function onSubmit() {
|
||||
emit('submit', { name: form.name.trim(), description: form.description.trim() })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.modal-footer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="map-switcher">
|
||||
<label class="map-switcher-label">Карта</label>
|
||||
<select
|
||||
class="form-control map-switcher-select"
|
||||
:value="modelValue"
|
||||
@change="onSelect"
|
||||
>
|
||||
<option v-for="map in maps" :key="map.id" :value="String(map.id)">
|
||||
{{ map.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="$emit('create')">
|
||||
+ Новая
|
||||
</button>
|
||||
<button
|
||||
v-if="modelValue"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
title="Настройки карты"
|
||||
@click="$emit('manage', modelValue)"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
maps: { type: Array, default: () => [] },
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'create', 'manage'])
|
||||
|
||||
function onSelect(event) {
|
||||
emit('update:modelValue', event.target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.map-switcher-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.map-switcher-select {
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@
|
||||
<p v-if="subtitle" class="network-map-sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="network-map-actions">
|
||||
<slot name="toolbar" />
|
||||
<slot name="filters" />
|
||||
<button class="btn btn-secondary btn-sm" @click="$emit('fit')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<select
|
||||
class="form-control"
|
||||
:value="modelValue"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RELATION_TYPES } from '../domain/networkChoices'
|
||||
|
||||
defineProps({
|
||||
modelValue: { type: String, default: 'acquaintance' },
|
||||
options: { type: Array, default: () => RELATION_TYPES },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function onChange(e) {
|
||||
emit('update:modelValue', e.target.value)
|
||||
}
|
||||
</script>
|
||||
@@ -2,12 +2,15 @@ import { getGraphBundle, getMapChoices } from '../application/usecases/graph'
|
||||
import { isLocalMode } from '../infrastructure/config/dataMode'
|
||||
import api from '../api'
|
||||
|
||||
export async function fetchGraphBundle(graphEndpoint = '/graph/') {
|
||||
export async function fetchGraphBundle({ graphEndpoint = '/graph/', mapId = null } = {}) {
|
||||
if (isLocalMode()) {
|
||||
return getGraphBundle({ networkMapOnly: graphEndpoint === '/network-map-graph/' })
|
||||
return getGraphBundle({ mapId })
|
||||
}
|
||||
const endpoint = mapId
|
||||
? `/network-map-graph/?map_id=${encodeURIComponent(mapId)}`
|
||||
: graphEndpoint
|
||||
const [gRes, rtRes] = await Promise.all([
|
||||
api.get(graphEndpoint),
|
||||
api.get(endpoint),
|
||||
api.get('/relation-types/'),
|
||||
])
|
||||
return {
|
||||
|
||||
@@ -5,30 +5,83 @@ export function useGraphNodeContextMenu() {
|
||||
const contextMenuNode = ref(null)
|
||||
const contextMenuX = ref(0)
|
||||
const contextMenuY = ref(0)
|
||||
const edgeContextMenuOpen = ref(false)
|
||||
const contextMenuEdge = ref(null)
|
||||
const edgeContextMenuX = ref(0)
|
||||
const edgeContextMenuY = ref(0)
|
||||
|
||||
function openContextMenu(node, event) {
|
||||
if (!node || !event) return
|
||||
edgeContextMenuOpen.value = false
|
||||
contextMenuEdge.value = null
|
||||
contextMenuNode.value = node
|
||||
contextMenuX.value = event.clientX
|
||||
contextMenuY.value = event.clientY
|
||||
contextMenuOpen.value = true
|
||||
}
|
||||
|
||||
function openEdgeContextMenu(edge, event) {
|
||||
if (!edge || !event) return
|
||||
contextMenuOpen.value = false
|
||||
contextMenuNode.value = null
|
||||
contextMenuEdge.value = edge
|
||||
edgeContextMenuX.value = event.clientX
|
||||
edgeContextMenuY.value = event.clientY
|
||||
edgeContextMenuOpen.value = true
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenuOpen.value = false
|
||||
contextMenuNode.value = null
|
||||
edgeContextMenuOpen.value = false
|
||||
contextMenuEdge.value = null
|
||||
}
|
||||
|
||||
function attachNodeContextHandlers(network, getNodes) {
|
||||
function closeEdgeContextMenu() {
|
||||
edgeContextMenuOpen.value = false
|
||||
contextMenuEdge.value = null
|
||||
}
|
||||
|
||||
function resolveEdgeAtPointer(network, domEvent, getEdges) {
|
||||
let edgeId = null
|
||||
if (domEvent && typeof network.getPointer === 'function' && typeof network.getEdgeAt === 'function') {
|
||||
try {
|
||||
edgeId = network.getEdgeAt(network.getPointer(domEvent))
|
||||
} catch {
|
||||
edgeId = null
|
||||
}
|
||||
}
|
||||
if (!edgeId) return null
|
||||
return getEdges().find((e) => String(e.id) === String(edgeId)) || null
|
||||
}
|
||||
|
||||
function attachNodeContextHandlers(network, getNodes, getEdges = () => []) {
|
||||
const onContext = (params) => {
|
||||
const domEvent = params.event?.srcEvent || params.event
|
||||
domEvent?.preventDefault?.()
|
||||
|
||||
let edge = null
|
||||
if (params.edges?.length > 0) {
|
||||
const edgeId = params.edges[0]
|
||||
edge = getEdges().find((e) => String(e.id) === String(edgeId)) || null
|
||||
}
|
||||
if (!edge) {
|
||||
edge = resolveEdgeAtPointer(network, domEvent, getEdges)
|
||||
}
|
||||
if (edge) {
|
||||
openEdgeContextMenu(edge, domEvent)
|
||||
return
|
||||
}
|
||||
|
||||
if (params.nodes?.length > 0) {
|
||||
const id = params.nodes[0]
|
||||
const node = getNodes().find((n) => String(n.id) === String(id))
|
||||
if (node) openContextMenu(node, domEvent)
|
||||
return
|
||||
if (node) {
|
||||
openContextMenu(node, domEvent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
@@ -41,8 +94,14 @@ export function useGraphNodeContextMenu() {
|
||||
contextMenuNode,
|
||||
contextMenuX,
|
||||
contextMenuY,
|
||||
edgeContextMenuOpen,
|
||||
contextMenuEdge,
|
||||
edgeContextMenuX,
|
||||
edgeContextMenuY,
|
||||
openContextMenu,
|
||||
openEdgeContextMenu,
|
||||
closeContextMenu,
|
||||
closeEdgeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,6 @@ export const NETWORK_CIRCLES = [
|
||||
|
||||
export const INTERACTION_INTENSITIES = [
|
||||
{ value: 'intense', label: 'Интенсивные контакты' },
|
||||
{ value: 'periodic', label: 'Периодические контакты' },
|
||||
{ value: 'sparse', label: 'Редкие контакты' },
|
||||
]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import Dexie from 'dexie'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
|
||||
const DEFAULT_MAP_NAME = 'Основная карта'
|
||||
|
||||
class SocialGraphDb extends Dexie {
|
||||
constructor() {
|
||||
@@ -9,7 +12,59 @@ class SocialGraphDb extends Dexie {
|
||||
meta: 'key',
|
||||
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
||||
})
|
||||
this.version(2).stores({
|
||||
contacts: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||
relations: 'id, source, target, updatedAt, deletedAt, workspaceId',
|
||||
networkMaps: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||
networkMapMemberships: 'id, mapId, contactId, updatedAt, deletedAt, [mapId+contactId]',
|
||||
meta: 'key',
|
||||
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
||||
}).upgrade(async (tx) => {
|
||||
const contacts = await tx.table('contacts').toArray()
|
||||
const ts = new Date().toISOString()
|
||||
const mapId = generateId()
|
||||
|
||||
await tx.table('networkMaps').add({
|
||||
id: mapId,
|
||||
name: DEFAULT_MAP_NAME,
|
||||
description: 'Мигрировано из прежней единой карты сети',
|
||||
workspaceId: 'personal',
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
deletedAt: null,
|
||||
})
|
||||
|
||||
for (const contact of contacts) {
|
||||
if (!contact.include_on_network_map) continue
|
||||
await tx.table('networkMapMemberships').add({
|
||||
id: generateId(),
|
||||
mapId,
|
||||
contactId: contact.id,
|
||||
life_sphere: contact.life_sphere || 'other',
|
||||
network_circle: contact.network_circle || 'productivity',
|
||||
importance: contact.importance ?? 3,
|
||||
map_angle: contact.map_angle ?? null,
|
||||
map_radius_ratio: contact.map_radius_ratio ?? null,
|
||||
workspaceId: 'personal',
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
deletedAt: null,
|
||||
})
|
||||
await tx.table('contacts').update(contact.id, {
|
||||
life_sphere: undefined,
|
||||
network_circle: undefined,
|
||||
importance: undefined,
|
||||
include_on_network_map: undefined,
|
||||
map_angle: undefined,
|
||||
map_radius_ratio: undefined,
|
||||
updatedAt: ts,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const localDb = new SocialGraphDb()
|
||||
export { DEFAULT_MAP_NAME }
|
||||
|
||||
@@ -13,12 +13,6 @@ function withDefaults(payload = {}) {
|
||||
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,
|
||||
@@ -122,5 +116,17 @@ export const localContactRepository = {
|
||||
})
|
||||
)
|
||||
)
|
||||
const memberships = await localDb.networkMapMemberships
|
||||
.filter((m) => !m.deletedAt && sameId(m.contactId, sid))
|
||||
.toArray()
|
||||
await Promise.all(
|
||||
memberships.map((m) =>
|
||||
localDb.networkMapMemberships.update(m.id, {
|
||||
deletedAt: ts,
|
||||
updatedAt: ts,
|
||||
version: Number(m.version || 1) + 1,
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { localDb } from '../db/localDb'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function sameId(a, b) {
|
||||
return String(a) === String(b)
|
||||
}
|
||||
|
||||
function withDefaults(payload = {}) {
|
||||
return {
|
||||
life_sphere: 'other',
|
||||
network_circle: 'productivity',
|
||||
importance: 3,
|
||||
map_angle: null,
|
||||
map_radius_ratio: null,
|
||||
workspaceId: 'personal',
|
||||
...payload,
|
||||
}
|
||||
}
|
||||
|
||||
async function findActiveMembership(id) {
|
||||
const direct = await localDb.networkMapMemberships.get(id)
|
||||
if (direct && !direct.deletedAt) return direct
|
||||
const sid = String(id)
|
||||
const all = await localDb.networkMapMemberships.toArray()
|
||||
return all.find((m) => !m.deletedAt && String(m.id) === sid) || null
|
||||
}
|
||||
|
||||
async function findByMapAndContact(mapId, contactId) {
|
||||
const all = await localDb.networkMapMemberships.toArray()
|
||||
return all.find(
|
||||
(m) => !m.deletedAt && sameId(m.mapId, mapId) && sameId(m.contactId, contactId)
|
||||
) || null
|
||||
}
|
||||
|
||||
async function hydrate(membership) {
|
||||
if (!membership || membership.deletedAt) return null
|
||||
const contact = await localDb.contacts.get(membership.contactId)
|
||||
const map = await localDb.networkMaps.get(membership.mapId)
|
||||
return {
|
||||
...membership,
|
||||
contact_name: contact?.name || '',
|
||||
map_name: map?.name || '',
|
||||
}
|
||||
}
|
||||
|
||||
export const localNetworkMapMembershipRepository = {
|
||||
async listByMap(mapId) {
|
||||
const all = await localDb.networkMapMemberships.toArray()
|
||||
const filtered = all
|
||||
.filter((m) => !m.deletedAt && sameId(m.mapId, mapId))
|
||||
.sort((a, b) => String(a.contactId).localeCompare(String(b.contactId)))
|
||||
const hydrated = await Promise.all(filtered.map(hydrate))
|
||||
return hydrated.filter(Boolean)
|
||||
},
|
||||
|
||||
async listByContact(contactId) {
|
||||
const all = await localDb.networkMapMemberships.toArray()
|
||||
const filtered = all.filter((m) => !m.deletedAt && sameId(m.contactId, contactId))
|
||||
const hydrated = await Promise.all(filtered.map(hydrate))
|
||||
return hydrated.filter(Boolean)
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const membership = await findActiveMembership(id)
|
||||
return hydrate(membership)
|
||||
},
|
||||
|
||||
async create(mapId, payload) {
|
||||
const existing = await findByMapAndContact(mapId, payload.contact)
|
||||
if (existing) {
|
||||
throw new Error('Контакт уже на этой карте')
|
||||
}
|
||||
const ts = nowIso()
|
||||
const record = withDefaults({
|
||||
...payload,
|
||||
mapId,
|
||||
contactId: payload.contact,
|
||||
})
|
||||
const id = generateId()
|
||||
await localDb.networkMapMemberships.put({
|
||||
...record,
|
||||
id,
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
deletedAt: null,
|
||||
})
|
||||
return this.getById(id)
|
||||
},
|
||||
|
||||
async update(mapId, id, payload) {
|
||||
const existing = await findActiveMembership(id)
|
||||
if (!existing) {
|
||||
throw new Error('Участие не найдено')
|
||||
}
|
||||
if (!sameId(existing.mapId, mapId)) {
|
||||
throw new Error('Участие не принадлежит указанной карте')
|
||||
}
|
||||
await localDb.networkMapMemberships.update(existing.id, {
|
||||
...payload,
|
||||
updatedAt: nowIso(),
|
||||
version: Number(existing.version || 1) + 1,
|
||||
})
|
||||
return this.getById(existing.id)
|
||||
},
|
||||
|
||||
async remove(mapId, id) {
|
||||
const membership = await findActiveMembership(id)
|
||||
if (!membership) {
|
||||
throw new Error('Участие не найдено')
|
||||
}
|
||||
if (!sameId(membership.mapId, mapId)) {
|
||||
throw new Error('Участие не принадлежит указанной карте')
|
||||
}
|
||||
const ts = nowIso()
|
||||
await localDb.networkMapMemberships.update(membership.id, {
|
||||
deletedAt: ts,
|
||||
updatedAt: ts,
|
||||
version: Number(membership.version || 1) + 1,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import api from '../../api'
|
||||
import { fetchAllPages } from '../../lib/api/pagination'
|
||||
|
||||
export const remoteNetworkMapMembershipRepository = {
|
||||
async listByMap(mapId) {
|
||||
return fetchAllPages((page) =>
|
||||
api.get(`/network-maps/${mapId}/memberships/`, { params: { page } })
|
||||
)
|
||||
},
|
||||
async listByContact(contactId) {
|
||||
const maps = await fetchAllPages((page) => api.get('/network-maps/', { params: { page } }))
|
||||
const results = []
|
||||
for (const map of maps) {
|
||||
const memberships = await fetchAllPages((page) =>
|
||||
api.get(`/network-maps/${map.id}/memberships/`, { params: { page } })
|
||||
)
|
||||
results.push(...memberships.filter((m) => String(m.contact) === String(contactId)))
|
||||
}
|
||||
return results
|
||||
},
|
||||
async getById(mapId, id) {
|
||||
const { data } = await api.get(`/network-maps/${mapId}/memberships/${id}/`)
|
||||
return data
|
||||
},
|
||||
async create(mapId, payload) {
|
||||
const { data } = await api.post(`/network-maps/${mapId}/memberships/`, payload)
|
||||
return data
|
||||
},
|
||||
async update(mapId, id, payload) {
|
||||
const { data } = await api.patch(`/network-maps/${mapId}/memberships/${id}/`, payload)
|
||||
return data
|
||||
},
|
||||
async remove(mapId, id) {
|
||||
await api.delete(`/network-maps/${mapId}/memberships/${id}/`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { localDb, DEFAULT_MAP_NAME } from '../db/localDb'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function withDefaults(payload = {}) {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
workspaceId: 'personal',
|
||||
...payload,
|
||||
}
|
||||
}
|
||||
|
||||
async function findActiveMap(id) {
|
||||
const direct = await localDb.networkMaps.get(id)
|
||||
if (direct && !direct.deletedAt) return direct
|
||||
const sid = String(id)
|
||||
const all = await localDb.networkMaps.toArray()
|
||||
return all.find((m) => !m.deletedAt && String(m.id) === sid) || null
|
||||
}
|
||||
|
||||
async function membershipsCount(mapId) {
|
||||
const all = await localDb.networkMapMemberships.toArray()
|
||||
return all.filter((m) => !m.deletedAt && String(m.mapId) === String(mapId)).length
|
||||
}
|
||||
|
||||
async function hydrate(map) {
|
||||
if (!map || map.deletedAt) return null
|
||||
return {
|
||||
...map,
|
||||
memberships_count: await membershipsCount(map.id),
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDefaultMap() {
|
||||
const all = await localDb.networkMaps.toArray()
|
||||
const active = all.filter((m) => !m.deletedAt)
|
||||
if (active.length) return active.sort((a, b) => a.name.localeCompare(b.name, 'ru'))[0]
|
||||
|
||||
const ts = nowIso()
|
||||
const id = generateId()
|
||||
await localDb.networkMaps.put({
|
||||
id,
|
||||
name: DEFAULT_MAP_NAME,
|
||||
description: '',
|
||||
workspaceId: 'personal',
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
deletedAt: null,
|
||||
})
|
||||
return findActiveMap(id)
|
||||
}
|
||||
|
||||
export const localNetworkMapRepository = {
|
||||
async list() {
|
||||
const all = await localDb.networkMaps.toArray()
|
||||
const filtered = all
|
||||
.filter((m) => !m.deletedAt)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||
if (!filtered.length) {
|
||||
const defaultMap = await ensureDefaultMap()
|
||||
return [await hydrate(defaultMap)]
|
||||
}
|
||||
const hydrated = await Promise.all(filtered.map(hydrate))
|
||||
return hydrated.filter(Boolean)
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const map = await findActiveMap(id)
|
||||
return hydrate(map)
|
||||
},
|
||||
|
||||
async create(payload) {
|
||||
const ts = nowIso()
|
||||
const record = withDefaults(payload)
|
||||
const id = generateId()
|
||||
await localDb.networkMaps.put({
|
||||
...record,
|
||||
id,
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
deletedAt: null,
|
||||
})
|
||||
return this.getById(id)
|
||||
},
|
||||
|
||||
async update(id, payload) {
|
||||
const existing = await findActiveMap(id)
|
||||
if (!existing) {
|
||||
throw new Error('Карта не найдена')
|
||||
}
|
||||
await localDb.networkMaps.update(existing.id, {
|
||||
...payload,
|
||||
updatedAt: nowIso(),
|
||||
version: Number(existing.version || 1) + 1,
|
||||
})
|
||||
return this.getById(existing.id)
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
const map = await findActiveMap(id)
|
||||
if (!map) {
|
||||
throw new Error('Карта не найдена')
|
||||
}
|
||||
const ts = nowIso()
|
||||
await localDb.networkMaps.update(map.id, {
|
||||
deletedAt: ts,
|
||||
updatedAt: ts,
|
||||
version: Number(map.version || 1) + 1,
|
||||
})
|
||||
const memberships = await localDb.networkMapMemberships
|
||||
.filter((m) => !m.deletedAt && String(m.mapId) === String(map.id))
|
||||
.toArray()
|
||||
await Promise.all(
|
||||
memberships.map((m) =>
|
||||
localDb.networkMapMemberships.update(m.id, {
|
||||
deletedAt: ts,
|
||||
updatedAt: ts,
|
||||
version: Number(m.version || 1) + 1,
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import api from '../../api'
|
||||
import { fetchAllPages } from '../../lib/api/pagination'
|
||||
|
||||
export const remoteNetworkMapRepository = {
|
||||
async list() {
|
||||
return fetchAllPages((page) => api.get('/network-maps/', { params: { page } }))
|
||||
},
|
||||
async getById(id) {
|
||||
const { data } = await api.get(`/network-maps/${id}/`)
|
||||
return data
|
||||
},
|
||||
async create(payload) {
|
||||
const { data } = await api.post('/network-maps/', payload)
|
||||
return data
|
||||
},
|
||||
async update(id, payload) {
|
||||
const { data } = await api.patch(`/network-maps/${id}/`, payload)
|
||||
return data
|
||||
},
|
||||
async remove(id) {
|
||||
await api.delete(`/network-maps/${id}/`)
|
||||
},
|
||||
}
|
||||
@@ -58,4 +58,19 @@ export const localRelationRepository = {
|
||||
version: Number(rel.version || 1) + 1,
|
||||
})
|
||||
},
|
||||
|
||||
async update(id, payload) {
|
||||
const rel = await localDb.relations.get(id)
|
||||
if (!rel || rel.deletedAt) {
|
||||
throw new Error('Связь не найдена')
|
||||
}
|
||||
await localDb.relations.update(id, {
|
||||
relation_type: payload.relation_type ?? rel.relation_type,
|
||||
description: payload.description ?? rel.description,
|
||||
interaction_intensity: payload.interaction_intensity ?? rel.interaction_intensity,
|
||||
updatedAt: nowIso(),
|
||||
version: Number(rel.version || 1) + 1,
|
||||
})
|
||||
return hydrate(await localDb.relations.get(id))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,4 +12,8 @@ export const remoteRelationRepository = {
|
||||
async remove(id) {
|
||||
await api.delete(`/relations/${id}/`)
|
||||
},
|
||||
async update(id, payload) {
|
||||
const { data } = await api.patch(`/relations/${id}/`, payload)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ import { RELATION_TYPES, LIFE_SPHERES, NETWORK_CIRCLES, INTERACTION_INTENSITIES
|
||||
import { getDataMode } from '../config/dataMode'
|
||||
import { localContactRepository } from './contactRepository.local'
|
||||
import { localRelationRepository } from './relationRepository.local'
|
||||
import { localNetworkMapRepository } from './networkMapRepository.local'
|
||||
import { localNetworkMapMembershipRepository } from './networkMapMembershipRepository.local'
|
||||
import { remoteContactRepository } from './contactRepository.remote'
|
||||
import { remoteRelationRepository } from './relationRepository.remote'
|
||||
import { remoteNetworkMapRepository } from './networkMapRepository.remote'
|
||||
import { remoteNetworkMapMembershipRepository } from './networkMapMembershipRepository.remote'
|
||||
|
||||
function mode() {
|
||||
return getDataMode()
|
||||
@@ -18,6 +22,14 @@ export function getRelationRepository() {
|
||||
return mode() === 'remote' ? remoteRelationRepository : localRelationRepository
|
||||
}
|
||||
|
||||
export function getNetworkMapRepository() {
|
||||
return mode() === 'remote' ? remoteNetworkMapRepository : localNetworkMapRepository
|
||||
}
|
||||
|
||||
export function getNetworkMapMembershipRepository() {
|
||||
return mode() === 'remote' ? remoteNetworkMapMembershipRepository : localNetworkMapMembershipRepository
|
||||
}
|
||||
|
||||
export async function getRelationTypes() {
|
||||
if (mode() === 'remote') {
|
||||
const { data } = await api.get('/relation-types/')
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { computeClusterMap } from './clusters'
|
||||
|
||||
function buildAdjacency(edges = []) {
|
||||
const adj = new Map()
|
||||
const add = (a, b) => {
|
||||
const sa = String(a)
|
||||
const sb = String(b)
|
||||
if (!adj.has(sa)) adj.set(sa, new Set())
|
||||
if (!adj.has(sb)) adj.set(sb, new Set())
|
||||
adj.get(sa).add(sb)
|
||||
adj.get(sb).add(sa)
|
||||
}
|
||||
for (const edge of edges) add(edge.from, edge.to)
|
||||
return adj
|
||||
}
|
||||
|
||||
function degree(adj, id) {
|
||||
return adj.get(String(id))?.size || 0
|
||||
}
|
||||
|
||||
function bfsLevels(memberIds, adj, startId) {
|
||||
const levels = new Map()
|
||||
const queue = [String(startId)]
|
||||
levels.set(String(startId), 0)
|
||||
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()
|
||||
const level = levels.get(cur)
|
||||
for (const next of adj.get(cur) || []) {
|
||||
if (!memberIds.has(next) || levels.has(next)) continue
|
||||
levels.set(next, level + 1)
|
||||
queue.push(next)
|
||||
}
|
||||
}
|
||||
|
||||
return levels
|
||||
}
|
||||
|
||||
/**
|
||||
* Начальные координаты узлов: кластеры разнесены, внутри — кольца от «хаба».
|
||||
*/
|
||||
export function computeGraphSeedPositions(nodes = [], edges = []) {
|
||||
const clusterMap = computeClusterMap(nodes, edges)
|
||||
const adj = buildAdjacency(edges)
|
||||
const positions = new Map()
|
||||
|
||||
const byCluster = new Map()
|
||||
for (const node of nodes) {
|
||||
const cid = clusterMap.get(String(node.id)) ?? -1
|
||||
if (!byCluster.has(cid)) byCluster.set(cid, [])
|
||||
byCluster.get(cid).push(node)
|
||||
}
|
||||
|
||||
const connectedClusterIds = [...byCluster.keys()].filter((k) => k >= 0).sort((a, b) => a - b)
|
||||
const clusterCount = connectedClusterIds.length
|
||||
|
||||
connectedClusterIds.forEach((cid, clusterIdx) => {
|
||||
const members = byCluster.get(cid)
|
||||
const memberIds = new Set(members.map((m) => String(m.id)))
|
||||
|
||||
const clusterAngle = (clusterIdx / Math.max(clusterCount, 1)) * 2 * Math.PI - Math.PI / 2
|
||||
const clusterDist = clusterCount > 1 ? 750 : 0
|
||||
const ccx = clusterDist * Math.cos(clusterAngle)
|
||||
const ccy = clusterDist * Math.sin(clusterAngle)
|
||||
|
||||
let hub = members[0]
|
||||
let maxDeg = -1
|
||||
for (const member of members) {
|
||||
const d = degree(adj, member.id)
|
||||
if (d > maxDeg) {
|
||||
maxDeg = d
|
||||
hub = member
|
||||
}
|
||||
}
|
||||
|
||||
const levels = bfsLevels(memberIds, adj, hub.id)
|
||||
let maxLevel = 0
|
||||
for (const member of members) {
|
||||
const sid = String(member.id)
|
||||
if (!levels.has(sid)) levels.set(sid, (maxDeg > 0 ? maxDeg : 1) + 1)
|
||||
maxLevel = Math.max(maxLevel, levels.get(sid))
|
||||
}
|
||||
|
||||
const byLevel = new Map()
|
||||
for (const member of members) {
|
||||
const lvl = levels.get(String(member.id)) || 0
|
||||
if (!byLevel.has(lvl)) byLevel.set(lvl, [])
|
||||
byLevel.get(lvl).push(member)
|
||||
}
|
||||
|
||||
const ringStep = 120 + Math.min(members.length, 24) * 4
|
||||
for (const [lvl, lvlNodes] of byLevel) {
|
||||
const crowdBoost = lvlNodes.length > 10 ? (lvlNodes.length - 10) * 6 : 0
|
||||
const radius = lvl === 0 ? 0 : lvl * ringStep + crowdBoost
|
||||
lvlNodes.forEach((member, index) => {
|
||||
const angle = (index / lvlNodes.length) * 2 * Math.PI - Math.PI / 2
|
||||
positions.set(String(member.id), {
|
||||
x: ccx + radius * Math.cos(angle),
|
||||
y: ccy + radius * Math.sin(angle),
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isolates = byCluster.get(-1) || []
|
||||
isolates.forEach((member, index) => {
|
||||
const angle = (index / Math.max(isolates.length, 1)) * 2 * Math.PI
|
||||
const radius = 900 + clusterCount * 120
|
||||
positions.set(String(member.id), {
|
||||
x: radius * Math.cos(angle),
|
||||
y: radius * Math.sin(angle),
|
||||
})
|
||||
})
|
||||
|
||||
return positions
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeGraphSeedPositions } from './graphLayout'
|
||||
|
||||
describe('computeGraphSeedPositions', () => {
|
||||
it('spreads disconnected clusters apart', () => {
|
||||
const nodes = [
|
||||
{ id: 'a1' },
|
||||
{ id: 'a2' },
|
||||
{ id: 'b1' },
|
||||
{ id: 'b2' },
|
||||
]
|
||||
const edges = [
|
||||
{ from: 'a1', to: 'a2' },
|
||||
{ from: 'b1', to: 'b2' },
|
||||
]
|
||||
const pos = computeGraphSeedPositions(nodes, edges)
|
||||
const a1 = pos.get('a1')
|
||||
const b1 = pos.get('b1')
|
||||
const dist = Math.hypot(a1.x - b1.x, a1.y - b1.y)
|
||||
expect(dist).toBeGreaterThan(400)
|
||||
})
|
||||
|
||||
it('places hub at cluster center ring', () => {
|
||||
const nodes = [{ id: 'hub' }, { id: 'n1' }, { id: 'n2' }]
|
||||
const edges = [
|
||||
{ from: 'hub', to: 'n1' },
|
||||
{ from: 'hub', to: 'n2' },
|
||||
]
|
||||
const pos = computeGraphSeedPositions(nodes, edges)
|
||||
const hub = pos.get('hub')
|
||||
const n1 = pos.get('n1')
|
||||
expect(Math.hypot(hub.x - n1.x, hub.y - n1.y)).toBeGreaterThan(50)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
const STORAGE_KEY = 'sg-graph-layout-v1'
|
||||
|
||||
const EMPTY = { positions: {}, scale: 1, view: null }
|
||||
|
||||
let memory = null
|
||||
|
||||
export function readGraphLayoutCache() {
|
||||
if (memory) return memory
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY)
|
||||
memory = raw ? { ...EMPTY, ...JSON.parse(raw) } : { ...EMPTY }
|
||||
} catch {
|
||||
memory = { ...EMPTY }
|
||||
}
|
||||
return memory
|
||||
}
|
||||
|
||||
export function writeGraphLayoutCache({ positions, scale, view }) {
|
||||
const prev = readGraphLayoutCache()
|
||||
memory = {
|
||||
positions: positions ?? prev.positions,
|
||||
scale: scale ?? prev.scale,
|
||||
view: view ?? prev.view,
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(memory))
|
||||
} catch {
|
||||
/* sessionStorage quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGraphLayoutCache() {
|
||||
memory = { ...EMPTY }
|
||||
sessionStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
readGraphLayoutCache,
|
||||
writeGraphLayoutCache,
|
||||
clearGraphLayoutCache,
|
||||
} from './graphLayoutCache'
|
||||
|
||||
describe('graphLayoutCache', () => {
|
||||
beforeEach(() => {
|
||||
clearGraphLayoutCache()
|
||||
})
|
||||
|
||||
it('stores and reads node positions', () => {
|
||||
writeGraphLayoutCache({
|
||||
positions: { '1': { x: 10, y: 20 } },
|
||||
scale: 1.5,
|
||||
view: { x: 0, y: 0 },
|
||||
})
|
||||
const cache = readGraphLayoutCache()
|
||||
expect(cache.positions['1']).toEqual({ x: 10, y: 20 })
|
||||
expect(cache.scale).toBe(1.5)
|
||||
expect(cache.view).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
const INTENSITY_STYLES = {
|
||||
intense: {
|
||||
width: 3.5,
|
||||
dashes: false,
|
||||
length: 95,
|
||||
opacity: 0.95,
|
||||
},
|
||||
periodic: {
|
||||
width: 2,
|
||||
dashes: [5, 5],
|
||||
length: 185,
|
||||
opacity: 0.8,
|
||||
},
|
||||
sparse: {
|
||||
width: 1,
|
||||
dashes: [8, 6],
|
||||
length: 300,
|
||||
opacity: 0.55,
|
||||
},
|
||||
}
|
||||
|
||||
export function normalizeIntensity(intensity) {
|
||||
if (intensity === 'sparse' || intensity === 'periodic' || intensity === 'intense') {
|
||||
return intensity
|
||||
}
|
||||
return 'intense'
|
||||
}
|
||||
|
||||
export function intensityVisual(intensity) {
|
||||
return INTENSITY_STYLES[normalizeIntensity(intensity)]
|
||||
}
|
||||
|
||||
export function applyIntensityToVisEdge(edge, colorBase) {
|
||||
const vis = intensityVisual(edge.interaction_intensity)
|
||||
return {
|
||||
width: vis.width,
|
||||
dashes: vis.dashes,
|
||||
length: vis.length,
|
||||
color: {
|
||||
color: colorBase.color,
|
||||
highlight: colorBase.highlight,
|
||||
opacity: vis.opacity,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { applyIntensityToVisEdge, intensityVisual, normalizeIntensity } from './relationIntensity'
|
||||
|
||||
describe('relationIntensity', () => {
|
||||
it('normalizes unknown values to intense', () => {
|
||||
expect(normalizeIntensity('unknown')).toBe('intense')
|
||||
expect(normalizeIntensity('periodic')).toBe('periodic')
|
||||
})
|
||||
|
||||
it('uses stronger pull for intense contacts', () => {
|
||||
const intense = intensityVisual('intense')
|
||||
const sparse = intensityVisual('sparse')
|
||||
expect(intense.length).toBeLessThan(sparse.length)
|
||||
expect(intense.width).toBeGreaterThan(sparse.width)
|
||||
})
|
||||
|
||||
it('maps periodic contacts between intense and sparse', () => {
|
||||
const periodic = intensityVisual('periodic')
|
||||
const intense = intensityVisual('intense')
|
||||
const sparse = intensityVisual('sparse')
|
||||
expect(periodic.length).toBeGreaterThan(intense.length)
|
||||
expect(periodic.length).toBeLessThan(sparse.length)
|
||||
})
|
||||
|
||||
it('applies color opacity from intensity', () => {
|
||||
const edge = applyIntensityToVisEdge(
|
||||
{ interaction_intensity: 'sparse' },
|
||||
{ color: '#fff', highlight: '#eee' }
|
||||
)
|
||||
expect(edge.color.opacity).toBeLessThan(1)
|
||||
expect(edge.dashes).toEqual([8, 6])
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,11 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/map',
|
||||
name: 'NetworkMapRedirect',
|
||||
component: () => import('../views/NetworkMapRedirect.vue'),
|
||||
},
|
||||
{
|
||||
path: '/map/:mapId',
|
||||
name: 'NetworkMap',
|
||||
component: () => import('../views/NetworkMapView.vue'),
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
listRelations,
|
||||
createRelation as createRelationUseCase,
|
||||
deleteRelation as deleteRelationUseCase,
|
||||
updateRelation as updateRelationUseCase,
|
||||
} from '../application/usecases/relations'
|
||||
import {
|
||||
getRelationTypes,
|
||||
@@ -35,6 +36,7 @@ export const useContactsStore = defineStore('contacts', {
|
||||
relationsLoading: false,
|
||||
mapLoading: false,
|
||||
error: null,
|
||||
dataRevision: 0,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -48,6 +50,10 @@ export const useContactsStore = defineStore('contacts', {
|
||||
this.error = error?.message || String(error)
|
||||
},
|
||||
|
||||
bumpDataRevision() {
|
||||
this.dataRevision += 1
|
||||
},
|
||||
|
||||
async withLoading(flagName, fn) {
|
||||
this[flagName] = true
|
||||
this.loading = this.contactsLoading || this.relationsLoading || this.mapLoading
|
||||
@@ -106,6 +112,7 @@ export const useContactsStore = defineStore('contacts', {
|
||||
const data = await createContactUseCase(payload)
|
||||
this.contacts.push(data)
|
||||
await syncPendingChanges()
|
||||
this.bumpDataRevision()
|
||||
return data
|
||||
})
|
||||
},
|
||||
@@ -116,6 +123,7 @@ export const useContactsStore = defineStore('contacts', {
|
||||
const idx = this.contacts.findIndex((c) => String(c.id) === String(id))
|
||||
if (idx !== -1) this.contacts[idx] = data
|
||||
await syncPendingChanges()
|
||||
this.bumpDataRevision()
|
||||
return data
|
||||
})
|
||||
},
|
||||
@@ -129,6 +137,7 @@ export const useContactsStore = defineStore('contacts', {
|
||||
(r) => String(r.source) !== sid && String(r.target) !== sid
|
||||
)
|
||||
await syncPendingChanges()
|
||||
this.bumpDataRevision()
|
||||
})
|
||||
},
|
||||
|
||||
@@ -143,6 +152,7 @@ export const useContactsStore = defineStore('contacts', {
|
||||
(r) => !idSet.has(String(r.source)) && !idSet.has(String(r.target))
|
||||
)
|
||||
await syncPendingChanges()
|
||||
this.bumpDataRevision()
|
||||
})
|
||||
},
|
||||
|
||||
@@ -160,6 +170,7 @@ export const useContactsStore = defineStore('contacts', {
|
||||
return c
|
||||
})
|
||||
await syncPendingChanges()
|
||||
this.bumpDataRevision()
|
||||
return data
|
||||
})
|
||||
},
|
||||
@@ -170,6 +181,18 @@ export const useContactsStore = defineStore('contacts', {
|
||||
this.relations = this.relations.filter((r) => r.id !== id)
|
||||
await this.fetchContacts()
|
||||
await syncPendingChanges()
|
||||
this.bumpDataRevision()
|
||||
})
|
||||
},
|
||||
|
||||
async updateRelation(id, payload) {
|
||||
return this.withLoading('relationsLoading', async () => {
|
||||
const data = await updateRelationUseCase(id, payload)
|
||||
const idx = this.relations.findIndex((r) => String(r.id) === String(id))
|
||||
if (idx !== -1) this.relations[idx] = data
|
||||
await syncPendingChanges()
|
||||
this.bumpDataRevision()
|
||||
return data
|
||||
})
|
||||
},
|
||||
|
||||
@@ -177,7 +200,11 @@ export const useContactsStore = defineStore('contacts', {
|
||||
return this.withLoading('contactsLoading', async () => {
|
||||
const data = await importContactsFromFile(file)
|
||||
await this.fetchContacts()
|
||||
if (data.isDump) {
|
||||
await this.fetchRelations()
|
||||
}
|
||||
await syncPendingChanges()
|
||||
this.bumpDataRevision()
|
||||
return data
|
||||
})
|
||||
},
|
||||
@@ -193,6 +220,7 @@ export const useContactsStore = defineStore('contacts', {
|
||||
async importDataDump(file, passphrase = '') {
|
||||
const result = await importLocalDump(file, passphrase)
|
||||
await Promise.all([this.fetchContacts(), this.fetchRelations()])
|
||||
this.bumpDataRevision()
|
||||
return result
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
listNetworkMaps,
|
||||
getNetworkMapById,
|
||||
createNetworkMap,
|
||||
updateNetworkMap,
|
||||
deleteNetworkMap,
|
||||
listMembershipsByMap,
|
||||
listMembershipsByContact,
|
||||
addContactToMap,
|
||||
updateMembership,
|
||||
removeContactFromMap,
|
||||
setContactMapMemberships,
|
||||
} from '../application/usecases/networkMaps'
|
||||
import { syncPendingChanges } from '../application/usecases/sync'
|
||||
|
||||
const ACTIVE_MAP_KEY = 'social-graph-active-map-id'
|
||||
|
||||
export const useNetworkMapsStore = defineStore('networkMaps', {
|
||||
state: () => ({
|
||||
maps: [],
|
||||
membershipsByMapId: {},
|
||||
contactMemberships: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
activeMapId: localStorage.getItem(ACTIVE_MAP_KEY) || null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
activeMap(state) {
|
||||
return state.maps.find((m) => String(m.id) === String(state.activeMapId)) || state.maps[0] || null
|
||||
},
|
||||
membershipsForActiveMap(state) {
|
||||
if (!state.activeMapId) return []
|
||||
return state.membershipsByMapId[String(state.activeMapId)] || []
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
setError(error) {
|
||||
this.error = error?.message || String(error)
|
||||
},
|
||||
|
||||
setActiveMapId(id) {
|
||||
this.activeMapId = id ? String(id) : null
|
||||
if (this.activeMapId) {
|
||||
localStorage.setItem(ACTIVE_MAP_KEY, this.activeMapId)
|
||||
} else {
|
||||
localStorage.removeItem(ACTIVE_MAP_KEY)
|
||||
}
|
||||
},
|
||||
|
||||
async fetchMaps() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
this.maps = await listNetworkMaps()
|
||||
if (!this.activeMapId && this.maps.length) {
|
||||
this.setActiveMapId(this.maps[0].id)
|
||||
}
|
||||
if (this.activeMapId && !this.maps.find((m) => String(m.id) === String(this.activeMapId))) {
|
||||
this.setActiveMapId(this.maps[0]?.id || null)
|
||||
}
|
||||
return this.maps
|
||||
} catch (e) {
|
||||
this.setError(e)
|
||||
throw e
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchMapById(id) {
|
||||
const data = await getNetworkMapById(id)
|
||||
const idx = this.maps.findIndex((m) => String(m.id) === String(id))
|
||||
if (idx !== -1) this.maps[idx] = data
|
||||
else if (data) this.maps.push(data)
|
||||
return data
|
||||
},
|
||||
|
||||
async fetchMemberships(mapId) {
|
||||
const data = await listMembershipsByMap(mapId)
|
||||
this.membershipsByMapId[String(mapId)] = data
|
||||
return data
|
||||
},
|
||||
|
||||
async fetchContactMemberships(contactId) {
|
||||
this.contactMemberships = await listMembershipsByContact(contactId)
|
||||
return this.contactMemberships
|
||||
},
|
||||
|
||||
async createMap(payload) {
|
||||
const data = await createNetworkMap(payload)
|
||||
this.maps.push(data)
|
||||
await syncPendingChanges()
|
||||
return data
|
||||
},
|
||||
|
||||
async updateMap(id, payload) {
|
||||
const data = await updateNetworkMap(id, payload)
|
||||
const idx = this.maps.findIndex((m) => String(m.id) === String(id))
|
||||
if (idx !== -1) this.maps[idx] = data
|
||||
await syncPendingChanges()
|
||||
return data
|
||||
},
|
||||
|
||||
async deleteMap(id) {
|
||||
await deleteNetworkMap(id)
|
||||
this.maps = this.maps.filter((m) => String(m.id) !== String(id))
|
||||
delete this.membershipsByMapId[String(id)]
|
||||
if (String(this.activeMapId) === String(id)) {
|
||||
this.setActiveMapId(this.maps[0]?.id || null)
|
||||
}
|
||||
await syncPendingChanges()
|
||||
},
|
||||
|
||||
async addContactToMap(mapId, contactId, extra = {}) {
|
||||
const data = await addContactToMap(mapId, contactId, extra)
|
||||
const key = String(mapId)
|
||||
const list = this.membershipsByMapId[key] || []
|
||||
this.membershipsByMapId[key] = [...list, data]
|
||||
await syncPendingChanges()
|
||||
return data
|
||||
},
|
||||
|
||||
async updateMembership(mapId, membershipId, payload) {
|
||||
const data = await updateMembership(mapId, membershipId, payload)
|
||||
const key = String(mapId)
|
||||
const list = this.membershipsByMapId[key] || []
|
||||
const idx = list.findIndex((m) => String(m.id) === String(membershipId))
|
||||
if (idx !== -1) {
|
||||
list[idx] = data
|
||||
this.membershipsByMapId[key] = [...list]
|
||||
}
|
||||
await syncPendingChanges()
|
||||
return data
|
||||
},
|
||||
|
||||
async removeContactFromMap(mapId, membershipId) {
|
||||
await removeContactFromMap(mapId, membershipId)
|
||||
const key = String(mapId)
|
||||
this.membershipsByMapId[key] = (this.membershipsByMapId[key] || [])
|
||||
.filter((m) => String(m.id) !== String(membershipId))
|
||||
await syncPendingChanges()
|
||||
},
|
||||
|
||||
async setContactMapMemberships(contactId, mapIds) {
|
||||
const data = await setContactMapMemberships(contactId, mapIds)
|
||||
this.contactMemberships = data
|
||||
await syncPendingChanges()
|
||||
return data
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -5,16 +5,16 @@
|
||||
<button class="btn btn-secondary btn-sm" @click="$router.back()">← Назад</button>
|
||||
<h2>{{ contact?.name || 'Загрузка...' }}</h2>
|
||||
</div>
|
||||
<div v-if="contact" class="page-header__actions">
|
||||
<button class="btn btn-primary btn-sm" @click="editing = true">Редактировать</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content" v-if="contact">
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px;">
|
||||
<!-- Info card -->
|
||||
<div class="card">
|
||||
<h3 style="font-size:14px;margin-bottom:16px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;">Информация</h3>
|
||||
<div class="card-section-header">
|
||||
<h3 class="card-section-title">Информация</h3>
|
||||
<button class="btn btn-primary btn-sm" type="button" @click="editing = true">Редактировать</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<div>{{ contact.email || '—' }}</div>
|
||||
@@ -36,15 +36,18 @@
|
||||
<div style="white-space:pre-wrap;">{{ contact.notes || '—' }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Сфера жизни / круг / важность</label>
|
||||
<div>
|
||||
{{ sphereLabel(contact.life_sphere) }} · {{ circleLabel(contact.network_circle) }}
|
||||
· важность {{ contact.importance ?? '—' }}/5
|
||||
<label>Карты сети</label>
|
||||
<div v-if="contactMapNames.length">
|
||||
<RouterLink
|
||||
v-for="name in contactMapNames"
|
||||
:key="name.id"
|
||||
:to="`/map/${name.id}`"
|
||||
class="map-link"
|
||||
>
|
||||
{{ name.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Карта сети</label>
|
||||
<div>{{ contact.include_on_network_map ? 'Показывается на карте' : 'Не на карте (только в общем графе)' }}</div>
|
||||
<div v-else>Не участвует ни на одной карте (только в общем графе)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,26 +60,40 @@
|
||||
<div v-if="contactRelations.length === 0" class="empty-state" style="padding:20px 0;">
|
||||
<p>Нет связей с другими контактами.</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else class="relations-list">
|
||||
<div
|
||||
v-for="rel in contactRelations"
|
||||
:key="rel.id"
|
||||
class="flex justify-between items-center"
|
||||
style="padding:8px 0; border-bottom:1px solid var(--border);"
|
||||
class="relation-row"
|
||||
:class="{ 'is-selected': String(editRelationTarget?.id) === String(rel.id) }"
|
||||
@click="openEditRelation(rel)"
|
||||
>
|
||||
<div>
|
||||
<span style="font-weight:500;">{{ rel.source === contact.id ? rel.target_name : rel.source_name }}</span>
|
||||
<div class="relation-row__body">
|
||||
<span style="font-weight:500;">{{ otherContactName(rel) }}</span>
|
||||
<span :class="`badge badge-${rel.relation_type}`" style="margin-left:8px;">{{ relLabel(rel.relation_type) }}</span>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">{{ intensityLabel(rel.interaction_intensity) }}</span>
|
||||
<div class="text-muted mt-1">{{ rel.description }}</div>
|
||||
<div v-if="rel.description" class="text-muted mt-1">{{ rel.description }}</div>
|
||||
</div>
|
||||
<div class="relation-row__actions" @click.stop>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="openEditRelation(rel)">
|
||||
Изменить
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" type="button" @click="removeRelation(rel.id)">✕</button>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm" @click="removeRelation(rel.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
@close="closeEditRelation"
|
||||
@updated="onRelationUpdated"
|
||||
@deleted="onRelationDeleted"
|
||||
/>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<div v-if="editing" class="modal-overlay" @click.self="editing = false">
|
||||
<div class="modal">
|
||||
@@ -84,7 +101,13 @@
|
||||
<h3>Редактировать контакт</h3>
|
||||
<button class="btn btn-secondary btn-sm" @click="editing = false">✕</button>
|
||||
</div>
|
||||
<ContactForm :initial="contact" @submit="onUpdate" @cancel="editing = false" />
|
||||
<ContactForm
|
||||
:initial="contact"
|
||||
deletable
|
||||
@submit="onUpdate"
|
||||
@cancel="editing = false"
|
||||
@delete="confirmDeleteContact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,19 +129,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Тип связи</label>
|
||||
<SearchableSelect
|
||||
v-model="newRel.type"
|
||||
:options="relationTypes"
|
||||
placeholder="Тип связи..."
|
||||
/>
|
||||
<RelationTypeSelect v-model="newRel.type" :options="relationTypes" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<SearchableSelect
|
||||
v-model="newRel.interaction_intensity"
|
||||
:options="interactionIntensities"
|
||||
placeholder="Интенсивность..."
|
||||
/>
|
||||
<InteractionIntensitySelect v-model="newRel.interaction_intensity" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание (необязательно)</label>
|
||||
@@ -138,19 +153,24 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import ContactForm from '../components/ContactForm.vue'
|
||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import SearchableSelect from '../components/SearchableSelect.vue'
|
||||
import InteractionIntensitySelect from '../components/InteractionIntensitySelect.vue'
|
||||
import RelationTypeSelect from '../components/RelationTypeSelect.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useContactsStore()
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const contact = ref(null)
|
||||
const editing = ref(false)
|
||||
const showAddRelation = ref(false)
|
||||
const editRelationOpen = ref(false)
|
||||
const editRelationTarget = ref(null)
|
||||
const relationTypes = ref([])
|
||||
const lifeSpheres = ref([])
|
||||
const networkCircles = ref([])
|
||||
const interactionIntensities = ref([])
|
||||
const relError = ref('')
|
||||
const newRel = ref({
|
||||
@@ -179,30 +199,56 @@ const contactSelectOptions = computed(() =>
|
||||
otherContacts.value.map((c) => ({ value: c.id, label: c.name }))
|
||||
)
|
||||
|
||||
const contactMapNames = computed(() => {
|
||||
const memberships = mapsStore.contactMemberships || []
|
||||
return memberships.map((m) => ({
|
||||
id: m.mapId || m.map,
|
||||
label: m.map_name || mapsStore.maps.find((map) => String(map.id) === String(m.mapId || m.map))?.name || 'Карта',
|
||||
}))
|
||||
})
|
||||
|
||||
function relLabel(type) {
|
||||
return relationTypes.value.find((r) => r.value === type)?.label || type
|
||||
}
|
||||
|
||||
function sphereLabel(v) {
|
||||
return lifeSpheres.value.find((x) => x.value === v)?.label || v || '—'
|
||||
}
|
||||
|
||||
function circleLabel(v) {
|
||||
return networkCircles.value.find((x) => x.value === v)?.label || v || '—'
|
||||
}
|
||||
|
||||
function intensityLabel(v) {
|
||||
return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
|
||||
}
|
||||
|
||||
async function loadContact() {
|
||||
contact.value = await store.fetchContactById(contactId.value)
|
||||
function otherContactName(rel) {
|
||||
const cid = String(contactId.value)
|
||||
return String(rel.source) === cid ? rel.target_name : rel.source_name
|
||||
}
|
||||
|
||||
async function onUpdate(data) {
|
||||
function openEditRelation(rel) {
|
||||
editRelationTarget.value = rel
|
||||
editRelationOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditRelation() {
|
||||
editRelationOpen.value = false
|
||||
editRelationTarget.value = null
|
||||
}
|
||||
|
||||
function onRelationUpdated() {
|
||||
closeEditRelation()
|
||||
}
|
||||
|
||||
function onRelationDeleted() {
|
||||
closeEditRelation()
|
||||
}
|
||||
|
||||
async function loadContact() {
|
||||
contact.value = await store.fetchContactById(contactId.value)
|
||||
await mapsStore.fetchContactMemberships(contactId.value)
|
||||
}
|
||||
|
||||
async function onUpdate(data, mapIds) {
|
||||
await store.updateContact(contactId.value, data)
|
||||
await mapsStore.setContactMapMemberships(contactId.value, mapIds)
|
||||
contact.value = { ...contact.value, ...data }
|
||||
editing.value = false
|
||||
await mapsStore.fetchContactMemberships(contactId.value)
|
||||
}
|
||||
|
||||
async function addRelation() {
|
||||
@@ -233,15 +279,10 @@ async function removeRelation(id) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await mapsStore.fetchMaps()
|
||||
await loadContact()
|
||||
await Promise.all([store.fetchContacts(), store.fetchRelations()])
|
||||
const [rt, mapChoices] = await Promise.all([
|
||||
store.fetchRelationTypes(),
|
||||
store.fetchNetworkMapChoices(),
|
||||
])
|
||||
const rt = await store.fetchRelationTypes()
|
||||
relationTypes.value = rt
|
||||
lifeSpheres.value = mapChoices.life_spheres
|
||||
networkCircles.value = mapChoices.network_circles
|
||||
interactionIntensities.value = mapChoices.interaction_intensities
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -41,6 +41,14 @@
|
||||
Ctrl+клик (⌘+клик на Mac) по двум контактам — создать связь.
|
||||
</p>
|
||||
|
||||
<div v-if="linkSelectionCount === 1" class="alert alert-info link-hint">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
Удерживайте Ctrl (⌘ на Mac) и кликните по второму контакту.
|
||||
</div>
|
||||
<p v-else class="text-muted link-hint link-hint--static">
|
||||
Ctrl+клик (⌘+клик на Mac) по двум контактам — создать связь.
|
||||
</p>
|
||||
|
||||
<div v-if="store.loading" class="spinner"></div>
|
||||
<div v-else-if="store.contacts.length === 0" class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -122,7 +130,13 @@
|
||||
<h3>Редактировать контакт</h3>
|
||||
<button class="btn btn-secondary btn-sm" @click="editTarget = null">✕</button>
|
||||
</div>
|
||||
<ContactForm :initial="editTarget" @submit="onUpdate" @cancel="editTarget = null" />
|
||||
<ContactForm
|
||||
:initial="editTarget"
|
||||
deletable
|
||||
@submit="onUpdate"
|
||||
@cancel="editTarget = null"
|
||||
@delete="onDeleteFromEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,11 +186,13 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
import ContactForm from '../components/ContactForm.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
|
||||
const store = useContactsStore()
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const router = useRouter()
|
||||
const search = ref('')
|
||||
const showCreate = ref(false)
|
||||
@@ -265,8 +281,11 @@ function onRelationCreated() {
|
||||
|
||||
function goTo(id) { router.push(`/contacts/${id}`) }
|
||||
|
||||
async function onCreate(data) {
|
||||
await store.createContact(data)
|
||||
async function onCreate(data, mapIds) {
|
||||
const created = await store.createContact(data)
|
||||
if (mapIds?.length) {
|
||||
await mapsStore.setContactMapMemberships(created.id, mapIds)
|
||||
}
|
||||
showCreate.value = false
|
||||
}
|
||||
|
||||
@@ -274,8 +293,9 @@ function openEdit(c) {
|
||||
editTarget.value = { ...c }
|
||||
}
|
||||
|
||||
async function onUpdate(data) {
|
||||
async function onUpdate(data, mapIds) {
|
||||
await store.updateContact(editTarget.value.id, data)
|
||||
await mapsStore.setContactMapMemberships(editTarget.value.id, mapIds)
|
||||
editTarget.value = null
|
||||
}
|
||||
|
||||
@@ -337,4 +357,15 @@ tr.is-link-selected {
|
||||
.link-hint--static {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
tr.is-link-selected {
|
||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--green);
|
||||
}
|
||||
.link-hint {
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.link-hint--static {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -77,6 +77,23 @@
|
||||
@info="openNodeInfo"
|
||||
/>
|
||||
|
||||
<GraphEdgeContextMenu
|
||||
:open="edgeContextMenuOpen"
|
||||
:edge="contextMenuEdge"
|
||||
:x="edgeContextMenuX"
|
||||
:y="edgeContextMenuY"
|
||||
@close="closeEdgeContextMenu"
|
||||
@edit="openEditRelation"
|
||||
/>
|
||||
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
@close="closeEditRelation"
|
||||
@updated="onRelationUpdated"
|
||||
@deleted="onRelationDeleted"
|
||||
/>
|
||||
|
||||
<CreateRelationModal
|
||||
:open="relationModalOpen"
|
||||
:source="relationPair?.[0]"
|
||||
@@ -88,7 +105,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
defineOptions({ name: 'Graph' })
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
@@ -96,13 +115,16 @@ import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||
import { clusterColor } from '../lib/graph/clusterColors'
|
||||
import { computeClusterMap } from '../lib/graph/clusters'
|
||||
import { RELATION_TYPES } from '../domain/networkChoices'
|
||||
import { fetchGraphBundle } from '../composables/useGraphData'
|
||||
import { edgeFromRelation } from '../application/usecases/graph'
|
||||
import { computeGraphSeedPositions } from '../lib/graph/graphLayout'
|
||||
import { readGraphLayoutCache, writeGraphLayoutCache } from '../lib/graph/graphLayoutCache'
|
||||
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
|
||||
import { buildGraphFromStore, edgeFromRelation } from '../application/usecases/graph'
|
||||
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
|
||||
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 EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
|
||||
let themeObserver = null
|
||||
@@ -113,7 +135,12 @@ const {
|
||||
contextMenuNode,
|
||||
contextMenuX,
|
||||
contextMenuY,
|
||||
edgeContextMenuOpen,
|
||||
contextMenuEdge,
|
||||
edgeContextMenuX,
|
||||
edgeContextMenuY,
|
||||
closeContextMenu,
|
||||
closeEdgeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
} = useGraphNodeContextMenu()
|
||||
|
||||
@@ -129,6 +156,8 @@ const physicsEnabled = ref(true)
|
||||
const selectedNode = ref(null)
|
||||
const relationModalOpen = ref(false)
|
||||
const relationPair = ref(null)
|
||||
const editRelationOpen = ref(false)
|
||||
const editRelationTarget = ref(null)
|
||||
|
||||
const {
|
||||
linkSelection,
|
||||
@@ -148,6 +177,8 @@ let initRetryCount = 0
|
||||
const INIT_RETRY_MAX = 40
|
||||
let initRetryTimer = null
|
||||
let resizeObserver = null
|
||||
let syncedRevision = -1
|
||||
let initialLayoutDone = false
|
||||
|
||||
const nodes = ref([])
|
||||
const edges = ref([])
|
||||
@@ -155,8 +186,6 @@ const allRelationTypes = ref([])
|
||||
const activeFilters = ref([])
|
||||
const clusterMap = ref(new Map())
|
||||
|
||||
const relationTypeLabels = Object.fromEntries(RELATION_TYPES.map((r) => [r.value, r.label]))
|
||||
|
||||
const selectedContact = computed(() =>
|
||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||
)
|
||||
@@ -217,14 +246,15 @@ function nodeVisColor(nodeId, linkIds) {
|
||||
function mapGraphNodeToVis(n, linkIds = new Set()) {
|
||||
const palette = graphPalette()
|
||||
const degree = nodeDegree(n.id)
|
||||
const label = degree <= 12 ? (n.label || String(n.id)) : ''
|
||||
return {
|
||||
id: String(n.id),
|
||||
label: n.label || String(n.id),
|
||||
title: n.title,
|
||||
label,
|
||||
title: [n.label, n.title].filter(Boolean).join('\n'),
|
||||
color: nodeVisColor(n.id, linkIds),
|
||||
font: { color: palette.nodeFont, size: degree > 0 ? 13 : 11 },
|
||||
font: { color: palette.nodeFont, size: degree > 0 ? 12 : 11 },
|
||||
shape: 'dot',
|
||||
size: degree > 0 ? 12 + Math.min(degree, 6) * 2 : 9,
|
||||
size: degree > 0 ? 10 + Math.min(degree, 4) * 1.5 : 8,
|
||||
borderWidth: linkIds.has(String(n.id)) ? 3 : 2,
|
||||
}
|
||||
}
|
||||
@@ -243,23 +273,93 @@ function applyLinkHighlights() {
|
||||
function mapGraphEdgeToVis(e) {
|
||||
const palette = graphPalette()
|
||||
const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
|
||||
const typeLabel = relationTypeLabels[e.relation_type] || e.relation_type
|
||||
const tip = [typeLabel, e.title !== e.relation_type ? e.title : ''].filter(Boolean).join(' — ')
|
||||
const intensity = applyIntensityToVisEdge(e, { color: rc.color, highlight: rc.highlight })
|
||||
return {
|
||||
id: String(e.id),
|
||||
from: String(e.from),
|
||||
to: String(e.to),
|
||||
title: tip || typeLabel,
|
||||
relation_type: e.relation_type,
|
||||
color: { color: rc.color, highlight: rc.highlight, opacity: 0.8 },
|
||||
width: e.interaction_intensity === 'sparse' ? 1 : 2,
|
||||
dashes: e.interaction_intensity === 'sparse' ? [6, 4] : false,
|
||||
interaction_intensity: e.interaction_intensity,
|
||||
...intensity,
|
||||
hoverWidth: intensity.width + 8,
|
||||
selectionWidth: intensity.width + 12,
|
||||
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'dynamic' },
|
||||
smooth: false,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRelationForEdit(edge) {
|
||||
const fromStore = store.relations.find((r) => String(r.id) === String(edge.id))
|
||||
if (fromStore) return fromStore
|
||||
const fromNode = nodes.value.find((n) => String(n.id) === String(edge.from))
|
||||
const toNode = nodes.value.find((n) => String(n.id) === String(edge.to))
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
relation_type: edge.relation_type,
|
||||
description: edge.title && edge.title !== edge.relation_type ? edge.title : '',
|
||||
interaction_intensity: edge.interaction_intensity || 'intense',
|
||||
source_name: fromNode?.label || String(edge.from),
|
||||
target_name: toNode?.label || String(edge.to),
|
||||
}
|
||||
}
|
||||
|
||||
function openEditRelation(edge) {
|
||||
editRelationTarget.value = resolveRelationForEdit(edge)
|
||||
editRelationOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditRelation() {
|
||||
editRelationOpen.value = false
|
||||
editRelationTarget.value = null
|
||||
}
|
||||
|
||||
function updateGraphEdge(relation) {
|
||||
const edge = edgeFromRelation(relation)
|
||||
const idx = edges.value.findIndex((e) => String(e.id) === String(edge.id))
|
||||
if (idx !== -1) edges.value[idx] = edge
|
||||
else edges.value.push(edge)
|
||||
|
||||
if (!edgesDS) return
|
||||
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
|
||||
const visible =
|
||||
activeFilters.value.length === allRelationTypes.value.length ||
|
||||
activeFilters.value.includes(edge.relation_type)
|
||||
if (!visible) {
|
||||
if (edgesDS.get(String(edge.id))) edgesDS.remove(String(edge.id))
|
||||
return
|
||||
}
|
||||
const visEdge = mapGraphEdgeToVis(edge)
|
||||
if (edgesDS.get(String(edge.id))) edgesDS.update(visEdge)
|
||||
else edgesDS.add(visEdge)
|
||||
}
|
||||
|
||||
function onRelationUpdated(relation) {
|
||||
closeEditRelation()
|
||||
updateGraphEdge(relation)
|
||||
syncedRevision = store.dataRevision
|
||||
if (network.value && physicsEnabled.value) {
|
||||
network.value.stabilize(80)
|
||||
}
|
||||
}
|
||||
|
||||
function removeGraphEdge(relationId) {
|
||||
const sid = String(relationId)
|
||||
edges.value = edges.value.filter((e) => String(e.id) !== sid)
|
||||
if (edgesDS?.get(sid)) edgesDS.remove(sid)
|
||||
recomputeClusters()
|
||||
refreshNodeStyles()
|
||||
}
|
||||
|
||||
function onRelationDeleted(relationId) {
|
||||
closeEditRelation()
|
||||
removeGraphEdge(relationId)
|
||||
syncedRevision = store.dataRevision
|
||||
}
|
||||
|
||||
function appendRelationEdge(relation) {
|
||||
if (!relation) return
|
||||
const edge = edgeFromRelation(relation)
|
||||
@@ -296,29 +396,157 @@ function onRelationCreated(relation) {
|
||||
clearLinkSelection()
|
||||
applyLinkHighlights()
|
||||
appendRelationEdge(relation)
|
||||
syncedRevision = store.dataRevision
|
||||
}
|
||||
|
||||
watch(linkSelection, () => {
|
||||
applyLinkHighlights()
|
||||
}, { deep: true })
|
||||
|
||||
async function loadGraph() {
|
||||
loading.value = true
|
||||
try {
|
||||
const bundle = await fetchGraphBundle('/graph/')
|
||||
nodes.value = bundle.nodes
|
||||
edges.value = bundle.edges
|
||||
allRelationTypes.value = bundle.relationTypes
|
||||
activeFilters.value = bundle.relationTypes.map((r) => r.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
function physicsOptions(enabled) {
|
||||
return {
|
||||
enabled,
|
||||
solver: 'forceAtlas2Based',
|
||||
forceAtlas2Based: {
|
||||
gravitationalConstant: -120,
|
||||
centralGravity: 0.002,
|
||||
springLength: 200,
|
||||
springConstant: 0.035,
|
||||
damping: 0.5,
|
||||
avoidOverlap: 1,
|
||||
},
|
||||
stabilization: enabled
|
||||
? { iterations: 200, fit: !initialLayoutDone, updateInterval: 25 }
|
||||
: undefined,
|
||||
maxVelocity: 20,
|
||||
}
|
||||
// Контейнер #graph-container в DOM только когда loading=false и nodes.length > 0
|
||||
if (nodes.value.length > 0) {
|
||||
recomputeClusters()
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
}
|
||||
|
||||
async function applyGraphDataFromStore() {
|
||||
if (!store.contacts.length) await store.fetchContacts()
|
||||
if (!store.relations.length) await store.fetchRelations()
|
||||
if (!store.relationTypes.length) {
|
||||
allRelationTypes.value = await store.fetchRelationTypes()
|
||||
} else {
|
||||
allRelationTypes.value = store.relationTypes
|
||||
}
|
||||
const bundle = buildGraphFromStore(store.contacts, store.relations, allRelationTypes.value)
|
||||
nodes.value = bundle.nodes
|
||||
edges.value = bundle.edges
|
||||
if (!activeFilters.value.length && allRelationTypes.value.length) {
|
||||
activeFilters.value = allRelationTypes.value.map((r) => r.value)
|
||||
}
|
||||
recomputeClusters()
|
||||
}
|
||||
|
||||
function saveLayoutSnapshot() {
|
||||
if (!network.value) return
|
||||
writeGraphLayoutCache({
|
||||
positions: network.value.getPositions(),
|
||||
scale: network.value.getScale(),
|
||||
view: network.value.getViewPosition(),
|
||||
})
|
||||
}
|
||||
|
||||
function restoreViewport() {
|
||||
if (!network.value) return
|
||||
const cache = readGraphLayoutCache()
|
||||
if (cache.view) {
|
||||
network.value.moveTo({
|
||||
position: cache.view,
|
||||
scale: cache.scale || 1,
|
||||
animation: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function finishInitialLayout({ fitView = true } = {}) {
|
||||
saveLayoutSnapshot()
|
||||
initialLayoutDone = true
|
||||
if (fitView) {
|
||||
network.value?.fit({ animation: false })
|
||||
}
|
||||
network.value?.redraw()
|
||||
}
|
||||
|
||||
function teardownNetwork() {
|
||||
if (initRetryTimer) {
|
||||
clearTimeout(initRetryTimer)
|
||||
initRetryTimer = null
|
||||
}
|
||||
detachContextHandler?.()
|
||||
detachContextHandler = null
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
network.value?.destroy()
|
||||
network.value = null
|
||||
nodesDS = null
|
||||
edgesDS = null
|
||||
}
|
||||
|
||||
function syncGraphToNetwork() {
|
||||
if (!network.value || !nodesDS || !edgesDS) return
|
||||
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
const livePositions = network.value.getPositions()
|
||||
const cachedPositions = readGraphLayoutCache().positions || {}
|
||||
const seeds = computeGraphSeedPositions(nodes.value, filteredEdges())
|
||||
|
||||
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||
nodesDS.getIds().forEach((id) => {
|
||||
if (!nextNodeIds.has(String(id))) nodesDS.remove(id)
|
||||
})
|
||||
|
||||
nodes.value.forEach((n) => {
|
||||
const id = String(n.id)
|
||||
const vis = mapGraphNodeToVis(n, linkIds)
|
||||
const pos = livePositions[id] || cachedPositions[id] || seeds.get(id)
|
||||
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
||||
if (nodesDS.get(id)) nodesDS.update(payload)
|
||||
else nodesDS.add(payload)
|
||||
})
|
||||
|
||||
const nextEdges = filteredEdges().map(mapGraphEdgeToVis)
|
||||
const nextEdgeIds = new Set(nextEdges.map((e) => String(e.id)))
|
||||
edgesDS.getIds().forEach((id) => {
|
||||
if (!nextEdgeIds.has(String(id))) edgesDS.remove(id)
|
||||
})
|
||||
nextEdges.forEach((edge) => {
|
||||
if (edgesDS.get(edge.id)) edgesDS.update(edge)
|
||||
else edgesDS.add(edge)
|
||||
})
|
||||
|
||||
recomputeClusters()
|
||||
refreshNodeStyles()
|
||||
syncedRevision = store.dataRevision
|
||||
}
|
||||
|
||||
async function ensureGraphReady({ showSpinner = true } = {}) {
|
||||
const reconnecting = Boolean(network.value)
|
||||
if (showSpinner && !reconnecting) loading.value = true
|
||||
try {
|
||||
await applyGraphDataFromStore()
|
||||
} finally {
|
||||
if (showSpinner && !reconnecting) loading.value = false
|
||||
}
|
||||
|
||||
if (nodes.value.length === 0) {
|
||||
teardownNetwork()
|
||||
syncedRevision = store.dataRevision
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
|
||||
if (!network.value) {
|
||||
initNetwork()
|
||||
syncedRevision = store.dataRevision
|
||||
return
|
||||
}
|
||||
|
||||
if (syncedRevision !== store.dataRevision) {
|
||||
syncGraphToNetwork()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +560,7 @@ function filteredEdges() {
|
||||
}
|
||||
|
||||
function initNetwork() {
|
||||
if (!graphContainer.value) return
|
||||
if (network.value || !graphContainer.value) return
|
||||
const el = graphContainer.value
|
||||
let width = el.offsetWidth
|
||||
let height = el.offsetHeight
|
||||
@@ -355,35 +583,46 @@ function initNetwork() {
|
||||
}
|
||||
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
const nodeList = nodes.value.map((n) => mapGraphNodeToVis(n, linkIds))
|
||||
const cache = readGraphLayoutCache()
|
||||
const cachedPositions = cache.positions || {}
|
||||
const nodeIds = nodes.value.map((n) => String(n.id))
|
||||
const cachedCount = nodeIds.filter((id) => cachedPositions[id]).length
|
||||
const useCachedLayout = cachedCount > 0 && cachedCount >= nodeIds.length * 0.8
|
||||
|
||||
const seedPositions = computeGraphSeedPositions(nodes.value, filteredEdges())
|
||||
const nodeList = nodes.value.map((n) => {
|
||||
const vis = mapGraphNodeToVis(n, linkIds)
|
||||
const id = String(n.id)
|
||||
const pos = cachedPositions[id] || seedPositions.get(id)
|
||||
return pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
||||
})
|
||||
const edgeList = filteredEdges().map(mapGraphEdgeToVis)
|
||||
|
||||
nodesDS = new DataSet(nodeList)
|
||||
edgesDS = new DataSet(edgeList)
|
||||
|
||||
if (useCachedLayout) {
|
||||
initialLayoutDone = true
|
||||
}
|
||||
|
||||
network.value = new Network(
|
||||
el,
|
||||
{ nodes: nodesDS, edges: edgesDS },
|
||||
{
|
||||
physics: {
|
||||
enabled: true,
|
||||
stabilization: { iterations: 250, fit: true },
|
||||
barnesHut: {
|
||||
gravitationalConstant: -12000,
|
||||
centralGravity: 0.15,
|
||||
springLength: 220,
|
||||
springConstant: 0.035,
|
||||
damping: 0.12,
|
||||
avoidOverlap: 0.25,
|
||||
},
|
||||
},
|
||||
layout: { improvedLayout: false },
|
||||
physics: physicsOptions(physicsEnabled.value),
|
||||
interaction: {
|
||||
tooltipDelay: 200,
|
||||
hover: true,
|
||||
hideEdgesOnDrag: true,
|
||||
selectConnectedEdges: false,
|
||||
zoomView: true,
|
||||
dragView: true,
|
||||
dragNodes: true,
|
||||
},
|
||||
edges: {
|
||||
chosen: { label: false },
|
||||
},
|
||||
nodes: { borderWidth: 1.5 },
|
||||
}
|
||||
)
|
||||
@@ -400,22 +639,25 @@ function initNetwork() {
|
||||
}
|
||||
})
|
||||
|
||||
detachContextHandler?.()
|
||||
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value)
|
||||
|
||||
network.value.once('stabilizationIterationsDone', () => {
|
||||
network.value?.fit({ animation: { duration: 400 } })
|
||||
setTimeout(() => {
|
||||
network.value?.fit({ animation: false })
|
||||
network.value?.redraw()
|
||||
}, 100)
|
||||
network.value.on('dragEnd', () => {
|
||||
saveLayoutSnapshot()
|
||||
})
|
||||
setTimeout(() => {
|
||||
if (network.value) {
|
||||
network.value.fit({ animation: false })
|
||||
network.value.redraw()
|
||||
}
|
||||
}, 800)
|
||||
|
||||
detachContextHandler?.()
|
||||
detachContextHandler = attachNodeContextHandlers(
|
||||
network.value,
|
||||
() => nodes.value,
|
||||
() => edges.value
|
||||
)
|
||||
|
||||
if (useCachedLayout) {
|
||||
restoreViewport()
|
||||
network.value.redraw()
|
||||
} else {
|
||||
network.value.once('stabilizationIterationsDone', () => {
|
||||
finishInitialLayout({ fitView: true })
|
||||
})
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
network.value?.redraw()
|
||||
@@ -457,21 +699,47 @@ function resetView() {
|
||||
|
||||
function togglePhysics() {
|
||||
physicsEnabled.value = !physicsEnabled.value
|
||||
network.value?.setOptions({ physics: { enabled: physicsEnabled.value } })
|
||||
network.value?.setOptions({ physics: physicsOptions(physicsEnabled.value) })
|
||||
}
|
||||
|
||||
watch(() => store.dataRevision, async (revision) => {
|
||||
if (!network.value || revision === syncedRevision) return
|
||||
await applyGraphDataFromStore()
|
||||
if (nodes.value.length === 0) {
|
||||
teardownNetwork()
|
||||
syncedRevision = revision
|
||||
return
|
||||
}
|
||||
syncGraphToNetwork()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadGraph()
|
||||
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
if (network.value) {
|
||||
network.value.redraw()
|
||||
if (syncedRevision !== store.dataRevision) {
|
||||
await ensureGraphReady({ showSpinner: false })
|
||||
} else {
|
||||
restoreViewport()
|
||||
}
|
||||
return
|
||||
}
|
||||
await ensureGraphReady()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
saveLayoutSnapshot()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (initRetryTimer) clearTimeout(initRetryTimer)
|
||||
detachContextHandler?.()
|
||||
saveLayoutSnapshot()
|
||||
closeContextMenu()
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
network.value?.destroy()
|
||||
teardownNetwork()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ END:VCARD</pre>
|
||||
<span v-if="result.error">{{ result.error }}</span>
|
||||
<span v-else>
|
||||
В файле: <strong>{{ result.total ?? result.created + result.skipped }}</strong>,
|
||||
импортировано: <strong>{{ result.created }}</strong>,
|
||||
импортировано: <strong>{{ result.created }}</strong> контактов<template v-if="result.importedRelations">, <strong>{{ result.importedRelations }}</strong> связей</template>,
|
||||
пропущено: {{ result.skipped }}.
|
||||
<span v-if="result.errors?.length"> Ошибок: {{ result.errors.length }}.</span>
|
||||
</span>
|
||||
@@ -141,8 +141,10 @@ END:VCARD</pre>
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
|
||||
const store = useContactsStore()
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const fileInput = ref(null)
|
||||
const selectedFile = ref(null)
|
||||
const isDragging = ref(false)
|
||||
@@ -177,6 +179,9 @@ async function doImport() {
|
||||
result.value = null
|
||||
try {
|
||||
result.value = await store.importContacts(selectedFile.value)
|
||||
if (result.value.isDump) {
|
||||
await mapsStore.fetchMaps()
|
||||
}
|
||||
} catch (e) {
|
||||
result.value = { error: e.message }
|
||||
} finally {
|
||||
@@ -234,11 +239,14 @@ async function onBackupFileSelect(e) {
|
||||
result.value = null
|
||||
try {
|
||||
const summary = await store.importDataDump(file, backupPassphrase.value)
|
||||
await mapsStore.fetchMaps()
|
||||
result.value = {
|
||||
total: summary.importedContacts + summary.importedRelations,
|
||||
created: summary.importedContacts,
|
||||
importedRelations: summary.importedRelations,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
isDump: true,
|
||||
}
|
||||
} catch (error) {
|
||||
result.value = { error: error?.message || 'Ошибка импорта бэкапа' }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="network-map-redirect">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
|
||||
const router = useRouter()
|
||||
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' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-redirect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,9 +2,23 @@
|
||||
<div class="network-map-view">
|
||||
<NetworkMapTopPanel
|
||||
:collapsed="topPanelCollapsed"
|
||||
:title="activeMap?.name || 'Карта сети'"
|
||||
:subtitle="activeMap?.description || ''"
|
||||
@toggle-collapse="topPanelCollapsed = !topPanelCollapsed"
|
||||
@fit="fitView"
|
||||
>
|
||||
<template #toolbar>
|
||||
<NetworkMapSwitcher
|
||||
:maps="mapsStore.maps"
|
||||
:model-value="String(mapId)"
|
||||
@update:model-value="switchMap"
|
||||
@create="openCreateMap"
|
||||
@manage="openEditMap"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="showAddContact = true">
|
||||
+ Участник
|
||||
</button>
|
||||
</template>
|
||||
<template #filters>
|
||||
<RelationTypeFilters
|
||||
:relation-types="allRelationTypes"
|
||||
@@ -25,9 +39,10 @@
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card">
|
||||
<p>
|
||||
На карте сети никого нет. Отметьте «Показывать на карте сети» в
|
||||
<RouterLink to="/contacts">карточках контактов</RouterLink>
|
||||
или добавьте новых.
|
||||
На карте «{{ activeMap?.name || 'сети' }}» никого нет.
|
||||
<button type="button" class="btn btn-link" @click="showAddContact = true">Добавьте участников</button>
|
||||
из общего списка контактов или создайте новых в
|
||||
<RouterLink to="/contacts">карточках контактов</RouterLink>.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="map-stack" ref="mapStack">
|
||||
@@ -41,13 +56,13 @@
|
||||
<h3>{{ selectedNode.label }}</h3>
|
||||
<button class="btn btn-secondary btn-sm" @click="selectedNode = null">✕</button>
|
||||
</div>
|
||||
<div v-if="selectedContact">
|
||||
<div v-if="selectedNode">
|
||||
<div class="form-group">
|
||||
<label>Сфера / круг / важность</label>
|
||||
<div>
|
||||
{{ sphereLabels[selectedContact.life_sphere] || selectedContact.life_sphere }}
|
||||
· {{ circleLabels[selectedContact.network_circle] || selectedContact.network_circle }}
|
||||
· {{ selectedContact.importance }}/5
|
||||
{{ sphereLabels[selectedNode.life_sphere] || selectedNode.life_sphere }}
|
||||
· {{ circleLabels[selectedNode.network_circle] || selectedNode.network_circle }}
|
||||
· {{ selectedNode.importance }}/5
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -70,6 +85,23 @@
|
||||
@info="openNodeInfo"
|
||||
/>
|
||||
|
||||
<GraphEdgeContextMenu
|
||||
:open="edgeContextMenuOpen"
|
||||
:edge="contextMenuEdge"
|
||||
:x="edgeContextMenuX"
|
||||
:y="edgeContextMenuY"
|
||||
@close="closeEdgeContextMenu"
|
||||
@edit="openEditRelation"
|
||||
/>
|
||||
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
@close="closeEditRelation"
|
||||
@updated="onRelationUpdated"
|
||||
@deleted="onRelationDeleted"
|
||||
/>
|
||||
|
||||
<CreateRelationModal
|
||||
:open="relationModalOpen"
|
||||
:source="relationPair?.[0]"
|
||||
@@ -77,16 +109,34 @@
|
||||
@close="closeRelationModal"
|
||||
@created="onRelationCreated"
|
||||
/>
|
||||
|
||||
<NetworkMapFormModal
|
||||
:open="mapFormOpen"
|
||||
:initial="mapFormTarget"
|
||||
:deletable="Boolean(mapFormTarget?.id)"
|
||||
@close="closeMapForm"
|
||||
@submit="onMapFormSubmit"
|
||||
@delete="onMapDelete"
|
||||
/>
|
||||
|
||||
<AddContactToMapModal
|
||||
:open="showAddContact"
|
||||
:contacts="store.contacts"
|
||||
:member-contact-ids="memberContactIds"
|
||||
@close="showAddContact = false"
|
||||
@add="onAddContactToMap"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated, nextTick, watch } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
|
||||
import {
|
||||
SPHERE_ORDER,
|
||||
ringRadius,
|
||||
@@ -99,9 +149,17 @@ import { fetchGraphBundle, fetchMapChoices } from '../composables/useGraphData'
|
||||
import { edgeFromRelation } from '../application/usecases/graph'
|
||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
||||
import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue'
|
||||
import NetworkMapFormModal from '../components/NetworkMapFormModal.vue'
|
||||
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 EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
|
||||
defineOptions({ name: 'NetworkMap' })
|
||||
let themeObserver = null
|
||||
let detachContextHandler = null
|
||||
|
||||
@@ -110,7 +168,12 @@ const {
|
||||
contextMenuNode,
|
||||
contextMenuX,
|
||||
contextMenuY,
|
||||
edgeContextMenuOpen,
|
||||
contextMenuEdge,
|
||||
edgeContextMenuX,
|
||||
edgeContextMenuY,
|
||||
closeContextMenu,
|
||||
closeEdgeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
} = useGraphNodeContextMenu()
|
||||
|
||||
@@ -126,7 +189,7 @@ function nodeById(id) {
|
||||
|
||||
async function persistNodePlacement(nodeId, canvasX, canvasY) {
|
||||
const node = nodeById(nodeId)
|
||||
if (!node) return
|
||||
if (!node || !node.membership_id) return
|
||||
const L = layout.value
|
||||
const dx = canvasX - L.cx
|
||||
const dy = canvasY - L.cy
|
||||
@@ -153,23 +216,14 @@ async function persistNodePlacement(nodeId, canvasX, canvasY) {
|
||||
refreshPositions()
|
||||
|
||||
try {
|
||||
await store.updateContact(node.id, {
|
||||
await mapsStore.updateMembership(mapId.value, node.membership_id, {
|
||||
life_sphere: nextSphere,
|
||||
network_circle: nextCircle,
|
||||
map_angle: angle,
|
||||
map_radius_ratio: ratio,
|
||||
})
|
||||
} catch (e) {
|
||||
// Если PATCH не прошел — откатываем карту к данным из API.
|
||||
await store.fetchContacts()
|
||||
const actual = store.contactById(node.id)
|
||||
if (actual) {
|
||||
node.life_sphere = actual.life_sphere
|
||||
node.network_circle = actual.network_circle
|
||||
node.map_angle = actual.map_angle
|
||||
node.map_radius_ratio = actual.map_radius_ratio
|
||||
refreshPositions()
|
||||
}
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +275,18 @@ function buildLabelMap(posById, scale = 1) {
|
||||
return map
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useContactsStore()
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
|
||||
const mapId = computed(() => String(route.params.mapId || ''))
|
||||
const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null)
|
||||
const memberContactIds = computed(() => nodes.value.map((n) => String(n.id)))
|
||||
|
||||
const mapFormOpen = ref(false)
|
||||
const mapFormTarget = ref({})
|
||||
const showAddContact = ref(false)
|
||||
const mapStack = ref(null)
|
||||
const graphContainer = ref(null)
|
||||
const loading = ref(true)
|
||||
@@ -229,6 +294,8 @@ const network = ref(null)
|
||||
const selectedNode = ref(null)
|
||||
const relationModalOpen = ref(false)
|
||||
const relationPair = ref(null)
|
||||
const editRelationOpen = ref(false)
|
||||
const editRelationTarget = ref(null)
|
||||
|
||||
const {
|
||||
linkSelection,
|
||||
@@ -321,6 +388,70 @@ function appendRelationEdge(relation) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRelationForEdit(edge) {
|
||||
const fromStore = store.relations.find((r) => String(r.id) === String(edge.id))
|
||||
if (fromStore) return fromStore
|
||||
const fromNode = nodes.value.find((n) => String(n.id) === String(edge.from))
|
||||
const toNode = nodes.value.find((n) => String(n.id) === String(edge.to))
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
relation_type: edge.relation_type,
|
||||
description: edge.title && edge.title !== edge.label ? edge.title : '',
|
||||
interaction_intensity: edge.interaction_intensity || 'intense',
|
||||
source_name: fromNode?.label || String(edge.from),
|
||||
target_name: toNode?.label || String(edge.to),
|
||||
}
|
||||
}
|
||||
|
||||
function openEditRelation(edge) {
|
||||
editRelationTarget.value = resolveRelationForEdit(edge)
|
||||
editRelationOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditRelation() {
|
||||
editRelationOpen.value = false
|
||||
editRelationTarget.value = null
|
||||
}
|
||||
|
||||
function updateGraphEdge(relation) {
|
||||
const edge = edgeFromRelation(relation)
|
||||
const idx = edges.value.findIndex((e) => String(e.id) === String(edge.id))
|
||||
if (idx !== -1) edges.value[idx] = edge
|
||||
else edges.value.push(edge)
|
||||
|
||||
if (!edgesDS) return
|
||||
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
|
||||
const visible =
|
||||
activeFilters.value.length === allRelationTypes.value.length ||
|
||||
activeFilters.value.includes(edge.relation_type)
|
||||
if (!visible) {
|
||||
if (edgesDS.get(String(edge.id))) edgesDS.remove(String(edge.id))
|
||||
return
|
||||
}
|
||||
const visEdge = mapEdgeToVis(edge)
|
||||
if (edgesDS.get(String(edge.id))) edgesDS.update(visEdge)
|
||||
else edgesDS.add(visEdge)
|
||||
}
|
||||
|
||||
function onRelationUpdated(relation) {
|
||||
closeEditRelation()
|
||||
updateGraphEdge(relation)
|
||||
}
|
||||
|
||||
function removeGraphEdge(relationId) {
|
||||
const sid = String(relationId)
|
||||
edges.value = edges.value.filter((e) => String(e.id) !== sid)
|
||||
if (edgesDS?.get(sid)) edgesDS.remove(sid)
|
||||
}
|
||||
|
||||
function onRelationDeleted(relationId) {
|
||||
closeEditRelation()
|
||||
removeGraphEdge(relationId)
|
||||
}
|
||||
|
||||
function closeRelationModal() {
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
@@ -443,17 +574,16 @@ function filteredEdges() {
|
||||
}
|
||||
|
||||
function mapEdgeToVis(e) {
|
||||
const intense = e.interaction_intensity !== 'sparse'
|
||||
const tip = [e.label, e.title].filter(Boolean).join(' — ') || 'Связь'
|
||||
const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
|
||||
const intensity = applyIntensityToVisEdge(e, { color: rc.color, highlight: rc.highlight })
|
||||
return {
|
||||
id: String(e.id),
|
||||
from: String(e.from),
|
||||
to: String(e.to),
|
||||
title: tip,
|
||||
relation_type: e.relation_type,
|
||||
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
|
||||
width: intense ? 2.2 : 1,
|
||||
dashes: intense ? false : [8, 6],
|
||||
...intensity,
|
||||
hoverWidth: intensity.width + 8,
|
||||
selectionWidth: intensity.width + 12,
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.65 } },
|
||||
smooth: false,
|
||||
}
|
||||
@@ -526,13 +656,14 @@ function initNetwork() {
|
||||
interaction: {
|
||||
tooltipDelay: 150,
|
||||
hover: true,
|
||||
selectConnectedEdges: false,
|
||||
zoomView: true,
|
||||
dragView: true,
|
||||
dragNodes: true,
|
||||
selectable: true,
|
||||
},
|
||||
nodes: { borderWidth: 1.5 },
|
||||
edges: { font: { size: 0 } },
|
||||
edges: { font: { size: 0 }, chosen: { label: false } },
|
||||
}
|
||||
)
|
||||
|
||||
@@ -551,7 +682,11 @@ function initNetwork() {
|
||||
})
|
||||
|
||||
detachContextHandler?.()
|
||||
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value)
|
||||
detachContextHandler = attachNodeContextHandlers(
|
||||
network.value,
|
||||
() => nodes.value,
|
||||
() => edges.value
|
||||
)
|
||||
|
||||
network.value.on('dragEnd', async (params) => {
|
||||
if (!params.nodes?.length) return
|
||||
@@ -642,10 +777,12 @@ function fitView() {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!mapId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
mapsStore.setActiveMapId(mapId.value)
|
||||
const [bundle, mapChoices] = await Promise.all([
|
||||
fetchGraphBundle('/network-map-graph/'),
|
||||
fetchGraphBundle({ mapId: mapId.value }),
|
||||
fetchMapChoices(),
|
||||
])
|
||||
nodes.value = bundle.nodes
|
||||
@@ -666,22 +803,94 @@ async function load() {
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
initNetwork()
|
||||
} else {
|
||||
network.value?.destroy()
|
||||
network.value = null
|
||||
nodesDS = null
|
||||
edgesDS = null
|
||||
}
|
||||
}
|
||||
|
||||
function switchMap(nextId) {
|
||||
if (!nextId || String(nextId) === mapId.value) return
|
||||
router.push({ name: 'NetworkMap', params: { mapId: nextId } })
|
||||
}
|
||||
|
||||
function openCreateMap() {
|
||||
mapFormTarget.value = {}
|
||||
mapFormOpen.value = true
|
||||
}
|
||||
|
||||
function openEditMap(id) {
|
||||
const map = mapsStore.maps.find((m) => String(m.id) === String(id))
|
||||
mapFormTarget.value = map ? { ...map } : { id }
|
||||
mapFormOpen.value = true
|
||||
}
|
||||
|
||||
function closeMapForm() {
|
||||
mapFormOpen.value = false
|
||||
mapFormTarget.value = {}
|
||||
}
|
||||
|
||||
async function onMapFormSubmit(payload) {
|
||||
if (mapFormTarget.value?.id) {
|
||||
await mapsStore.updateMap(mapFormTarget.value.id, payload)
|
||||
closeMapForm()
|
||||
} else {
|
||||
const created = await mapsStore.createMap(payload)
|
||||
closeMapForm()
|
||||
router.push({ name: 'NetworkMap', params: { mapId: created.id } })
|
||||
}
|
||||
}
|
||||
|
||||
async function onMapDelete() {
|
||||
if (!mapFormTarget.value?.id) return
|
||||
const deletingId = mapFormTarget.value.id
|
||||
await mapsStore.deleteMap(deletingId)
|
||||
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' })
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddContactToMap(contactId) {
|
||||
await mapsStore.addContactToMap(mapId.value, contactId)
|
||||
showAddContact.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
watch(mapId, async (next, prev) => {
|
||||
if (!next || next === prev) return
|
||||
network.value?.destroy()
|
||||
network.value = null
|
||||
nodesDS = null
|
||||
edgesDS = null
|
||||
await load()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
if (network.value) {
|
||||
network.value.redraw()
|
||||
return
|
||||
}
|
||||
await store.fetchRelations()
|
||||
await load()
|
||||
await nextTick()
|
||||
const stack = mapStack.value
|
||||
if (stack) {
|
||||
if (stack && !resizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
refreshPositions()
|
||||
network.value?.redraw()
|
||||
})
|
||||
resizeObserver.observe(stack)
|
||||
}
|
||||
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
Reference in New Issue
Block a user