Compare commits
4
Commits
ce4e9ac5e6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6eff89642 | ||
|
|
f5937040fa | ||
|
|
986d36ff51 | ||
|
|
10e7d65a00 |
@@ -0,0 +1,387 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from contacts.models import (
|
||||||
|
Contact,
|
||||||
|
NetworkMap,
|
||||||
|
NetworkMapMembership,
|
||||||
|
NetworkMapType,
|
||||||
|
Relation,
|
||||||
|
)
|
||||||
|
from core.choices import ALL_RELATION_TYPES, INTERACTION_INTENSITY
|
||||||
|
|
||||||
|
VALID_RELATION_TYPES = {choice[0] for choice in ALL_RELATION_TYPES}
|
||||||
|
VALID_INTENSITY = {choice[0] for choice in INTERACTION_INTENSITY}
|
||||||
|
|
||||||
|
|
||||||
|
def is_data_dump(data):
|
||||||
|
return (
|
||||||
|
isinstance(data, dict)
|
||||||
|
and isinstance(data.get('contacts'), list)
|
||||||
|
and isinstance(data.get('relations'), list)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_filter(owner):
|
||||||
|
return {'owner': owner} if owner is not None else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _contacts_qs(owner):
|
||||||
|
qs = Contact.objects.all()
|
||||||
|
if owner is not None:
|
||||||
|
qs = qs.filter(owner=owner)
|
||||||
|
return qs.order_by('name')
|
||||||
|
|
||||||
|
|
||||||
|
def _relations_qs(owner):
|
||||||
|
qs = Relation.objects.select_related('source', 'target').all()
|
||||||
|
if owner is not None:
|
||||||
|
qs = qs.filter(owner=owner)
|
||||||
|
return qs.order_by('id')
|
||||||
|
|
||||||
|
|
||||||
|
def _map_types_qs(owner):
|
||||||
|
qs = NetworkMapType.objects.all()
|
||||||
|
if owner is not None:
|
||||||
|
qs = qs.filter(owner=owner)
|
||||||
|
return qs.order_by('name')
|
||||||
|
|
||||||
|
|
||||||
|
def _maps_qs(owner):
|
||||||
|
qs = NetworkMap.objects.select_related('map_type').all()
|
||||||
|
if owner is not None:
|
||||||
|
qs = qs.filter(owner=owner)
|
||||||
|
return qs.order_by('name')
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_contact(contact):
|
||||||
|
return {
|
||||||
|
'id': contact.id,
|
||||||
|
'name': contact.name,
|
||||||
|
'email': contact.email or '',
|
||||||
|
'phone': contact.phone or '',
|
||||||
|
'organization': contact.organization or '',
|
||||||
|
'position': contact.position or '',
|
||||||
|
'notes': contact.notes or '',
|
||||||
|
'created_at': contact.created_at.isoformat() if contact.created_at else None,
|
||||||
|
'updated_at': contact.updated_at.isoformat() if contact.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_relation(relation):
|
||||||
|
return {
|
||||||
|
'id': relation.id,
|
||||||
|
'source': relation.source_id,
|
||||||
|
'source_name': relation.source.name if relation.source_id else '',
|
||||||
|
'target': relation.target_id,
|
||||||
|
'target_name': relation.target.name if relation.target_id else '',
|
||||||
|
'relation_type': relation.relation_type,
|
||||||
|
'description': relation.description or '',
|
||||||
|
'interaction_intensity': relation.interaction_intensity,
|
||||||
|
'created_at': relation.created_at.isoformat() if relation.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_map_type(map_type):
|
||||||
|
return {
|
||||||
|
'id': map_type.id,
|
||||||
|
'name': map_type.name,
|
||||||
|
'sectors': map_type.sectors or [],
|
||||||
|
'circles': map_type.circles or [],
|
||||||
|
'isDefault': map_type.is_default,
|
||||||
|
'conflictologyEnabled': map_type.conflictology_enabled,
|
||||||
|
'createdAt': map_type.created_at.isoformat() if map_type.created_at else None,
|
||||||
|
'updatedAt': map_type.updated_at.isoformat() if map_type.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_map(network_map):
|
||||||
|
return {
|
||||||
|
'id': network_map.id,
|
||||||
|
'name': network_map.name,
|
||||||
|
'description': network_map.description or '',
|
||||||
|
'mapTypeId': network_map.map_type_id,
|
||||||
|
'conflictSubject': network_map.conflict_subject or '',
|
||||||
|
'createdAt': network_map.created_at.isoformat() if network_map.created_at else None,
|
||||||
|
'updatedAt': network_map.updated_at.isoformat() if network_map.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_membership(membership):
|
||||||
|
return {
|
||||||
|
'id': membership.id,
|
||||||
|
'mapId': membership.map_id,
|
||||||
|
'contactId': membership.contact_id,
|
||||||
|
'life_sphere': membership.life_sphere,
|
||||||
|
'network_circle': membership.network_circle,
|
||||||
|
'importance': membership.importance,
|
||||||
|
'conflict_involvement': membership.conflict_involvement,
|
||||||
|
'map_angle': membership.map_angle,
|
||||||
|
'map_radius_ratio': membership.map_radius_ratio,
|
||||||
|
'createdAt': membership.created_at.isoformat() if membership.created_at else None,
|
||||||
|
'updatedAt': membership.updated_at.isoformat() if membership.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_owner_dump(owner):
|
||||||
|
contacts = [_serialize_contact(c) for c in _contacts_qs(owner)]
|
||||||
|
relations = [_serialize_relation(r) for r in _relations_qs(owner)]
|
||||||
|
map_types = [_serialize_map_type(t) for t in _map_types_qs(owner)]
|
||||||
|
maps = [_serialize_map(m) for m in _maps_qs(owner)]
|
||||||
|
|
||||||
|
map_ids = [m.id for m in _maps_qs(owner)]
|
||||||
|
memberships = NetworkMapMembership.objects.filter(map_id__in=map_ids).select_related('map', 'contact')
|
||||||
|
membership_rows = [_serialize_membership(m) for m in memberships]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'version': 2,
|
||||||
|
'exportedAt': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'contacts': contacts,
|
||||||
|
'relations': relations,
|
||||||
|
'networkMapTypes': map_types,
|
||||||
|
'networkMaps': maps,
|
||||||
|
'networkMapMemberships': membership_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_map_types(data):
|
||||||
|
raw = data.get('networkMapTypes') or data.get('network_map_types') or []
|
||||||
|
return raw if isinstance(raw, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _read_maps(data):
|
||||||
|
raw = data.get('networkMaps') or data.get('network_maps') or []
|
||||||
|
return raw if isinstance(raw, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _read_memberships(data):
|
||||||
|
raw = data.get('networkMapMemberships') or data.get('network_map_memberships') or []
|
||||||
|
return raw if isinstance(raw, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_payload(row):
|
||||||
|
return {
|
||||||
|
'name': str(row.get('name') or '').strip() or 'Без имени',
|
||||||
|
'email': str(row.get('email') or '').strip(),
|
||||||
|
'phone': str(row.get('phone') or '').strip(),
|
||||||
|
'organization': str(row.get('organization') or '').strip(),
|
||||||
|
'position': str(row.get('position') or '').strip(),
|
||||||
|
'notes': str(row.get('notes') or '').strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_relation_type(value):
|
||||||
|
raw = str(value or 'acquaintance').strip()
|
||||||
|
return raw if raw in VALID_RELATION_TYPES else 'other'
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_intensity(value):
|
||||||
|
raw = str(value or 'intense').strip()
|
||||||
|
return raw if raw in VALID_INTENSITY else 'intense'
|
||||||
|
|
||||||
|
|
||||||
|
def _relation_payload(row, source_id, target_id):
|
||||||
|
return {
|
||||||
|
'source_id': source_id,
|
||||||
|
'target_id': target_id,
|
||||||
|
'relation_type': _normalize_relation_type(row.get('relation_type')),
|
||||||
|
'description': str(row.get('description') or '')[:255],
|
||||||
|
'interaction_intensity': _normalize_intensity(row.get('interaction_intensity')),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _map_type_payload(row):
|
||||||
|
return {
|
||||||
|
'name': str(row.get('name') or '').strip() or 'Тип карты',
|
||||||
|
'sectors': row.get('sectors') or [],
|
||||||
|
'circles': row.get('circles') or [],
|
||||||
|
'is_default': bool(row.get('isDefault') if 'isDefault' in row else row.get('is_default')),
|
||||||
|
'conflictology_enabled': bool(
|
||||||
|
row.get('conflictologyEnabled') if 'conflictologyEnabled' in row
|
||||||
|
else row.get('conflictology_enabled')
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _map_payload(row, map_type_id):
|
||||||
|
return {
|
||||||
|
'name': str(row.get('name') or '').strip() or 'Карта',
|
||||||
|
'description': str(row.get('description') or '').strip(),
|
||||||
|
'map_type_id': map_type_id,
|
||||||
|
'conflict_subject': str(
|
||||||
|
row.get('conflictSubject') if 'conflictSubject' in row else row.get('conflict_subject') or ''
|
||||||
|
).strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _membership_payload(row, map_id, contact_id):
|
||||||
|
return {
|
||||||
|
'map_id': map_id,
|
||||||
|
'contact_id': contact_id,
|
||||||
|
'life_sphere': str(row.get('life_sphere') or 'other'),
|
||||||
|
'network_circle': str(row.get('network_circle') or 'productivity'),
|
||||||
|
'importance': int(row.get('importance') or 3),
|
||||||
|
'conflict_involvement': int(row.get('conflict_involvement') or 3),
|
||||||
|
'map_angle': row.get('map_angle'),
|
||||||
|
'map_radius_ratio': row.get('map_radius_ratio'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_owner_data(owner):
|
||||||
|
maps_qs = _maps_qs(owner)
|
||||||
|
map_ids = list(maps_qs.values_list('id', flat=True))
|
||||||
|
deleted_memberships = NetworkMapMembership.objects.filter(map_id__in=map_ids).count()
|
||||||
|
deleted_maps = maps_qs.count()
|
||||||
|
deleted_relations = _relations_qs(owner).count()
|
||||||
|
deleted_contacts = _contacts_qs(owner).count()
|
||||||
|
deleted_map_types = _map_types_qs(owner).count()
|
||||||
|
|
||||||
|
NetworkMapMembership.objects.filter(map_id__in=map_ids).delete()
|
||||||
|
maps_qs.delete()
|
||||||
|
_relations_qs(owner).delete()
|
||||||
|
_contacts_qs(owner).delete()
|
||||||
|
_map_types_qs(owner).delete()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'deletedContacts': deleted_contacts,
|
||||||
|
'deletedRelations': deleted_relations,
|
||||||
|
'deletedMaps': deleted_maps,
|
||||||
|
'deletedMemberships': deleted_memberships,
|
||||||
|
'deletedMapTypes': deleted_map_types,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def import_owner_dump(owner, data, replace=False):
|
||||||
|
if not is_data_dump(data):
|
||||||
|
raise ValueError('Некорректный формат бэкапа: ожидаются массивы contacts и relations.')
|
||||||
|
|
||||||
|
cleared = None
|
||||||
|
if replace:
|
||||||
|
cleared = clear_owner_data(owner)
|
||||||
|
|
||||||
|
contacts = data.get('contacts') or []
|
||||||
|
relations = data.get('relations') or []
|
||||||
|
map_types = _read_map_types(data)
|
||||||
|
maps = _read_maps(data)
|
||||||
|
memberships = _read_memberships(data)
|
||||||
|
|
||||||
|
contact_id_map = {}
|
||||||
|
contacts_created = 0
|
||||||
|
contacts_skipped = 0
|
||||||
|
|
||||||
|
for row in contacts:
|
||||||
|
payload = _contact_payload(row)
|
||||||
|
if not payload['name']:
|
||||||
|
contacts_skipped += 1
|
||||||
|
continue
|
||||||
|
create_kwargs = {**payload, **_owner_filter(owner)}
|
||||||
|
contact = Contact.objects.create(**create_kwargs)
|
||||||
|
old_id = row.get('id')
|
||||||
|
if old_id is not None:
|
||||||
|
contact_id_map[str(old_id)] = contact.id
|
||||||
|
contacts_created += 1
|
||||||
|
|
||||||
|
if not contact_id_map and contacts:
|
||||||
|
raise ValueError('Не удалось импортировать ни одного контакта.')
|
||||||
|
|
||||||
|
type_id_map = {}
|
||||||
|
existing_types = {t.name: t for t in _map_types_qs(owner)}
|
||||||
|
default_type_id = None
|
||||||
|
|
||||||
|
for row in map_types:
|
||||||
|
payload = _map_type_payload(row)
|
||||||
|
found = existing_types.get(payload['name'])
|
||||||
|
if found:
|
||||||
|
type_id_map[str(row.get('id'))] = found.id
|
||||||
|
if payload['is_default']:
|
||||||
|
default_type_id = found.id
|
||||||
|
continue
|
||||||
|
create_kwargs = {**payload, **_owner_filter(owner)}
|
||||||
|
created = NetworkMapType.objects.create(**create_kwargs)
|
||||||
|
existing_types[payload['name']] = created
|
||||||
|
type_id_map[str(row.get('id'))] = created.id
|
||||||
|
if payload['is_default']:
|
||||||
|
default_type_id = created.id
|
||||||
|
|
||||||
|
if not default_type_id:
|
||||||
|
fallback = _map_types_qs(owner).filter(is_default=True).first() or _map_types_qs(owner).first()
|
||||||
|
default_type_id = fallback.id if fallback else None
|
||||||
|
|
||||||
|
map_id_map = {}
|
||||||
|
maps_created = 0
|
||||||
|
|
||||||
|
for row in maps:
|
||||||
|
old_type_id = row.get('mapTypeId') if 'mapTypeId' in row else row.get('map_type')
|
||||||
|
map_type_id = type_id_map.get(str(old_type_id)) or default_type_id
|
||||||
|
if not map_type_id:
|
||||||
|
continue
|
||||||
|
payload = _map_payload(row, map_type_id)
|
||||||
|
create_kwargs = {**payload, **_owner_filter(owner)}
|
||||||
|
created = NetworkMap.objects.create(**create_kwargs)
|
||||||
|
map_id_map[str(row.get('id'))] = created.id
|
||||||
|
maps_created += 1
|
||||||
|
|
||||||
|
relation_pairs = set()
|
||||||
|
relations_created = 0
|
||||||
|
relations_skipped = 0
|
||||||
|
|
||||||
|
for row in relations:
|
||||||
|
source = contact_id_map.get(str(row.get('source')))
|
||||||
|
target = contact_id_map.get(str(row.get('target')))
|
||||||
|
if not source or not target or source == target:
|
||||||
|
relations_skipped += 1
|
||||||
|
continue
|
||||||
|
pair_key = f'{source}:{target}'
|
||||||
|
if pair_key in relation_pairs:
|
||||||
|
relations_skipped += 1
|
||||||
|
continue
|
||||||
|
payload = _relation_payload(row, source, target)
|
||||||
|
create_kwargs = {**payload, **_owner_filter(owner)}
|
||||||
|
Relation.objects.create(**create_kwargs)
|
||||||
|
relation_pairs.add(pair_key)
|
||||||
|
relations_created += 1
|
||||||
|
|
||||||
|
memberships_created = 0
|
||||||
|
memberships_skipped = 0
|
||||||
|
|
||||||
|
for row in memberships:
|
||||||
|
map_id = map_id_map.get(str(row.get('mapId') if 'mapId' in row else row.get('map')))
|
||||||
|
contact_id = contact_id_map.get(str(row.get('contactId') if 'contactId' in row else row.get('contact')))
|
||||||
|
if not map_id or not contact_id:
|
||||||
|
memberships_skipped += 1
|
||||||
|
continue
|
||||||
|
payload = _membership_payload(row, map_id, contact_id)
|
||||||
|
NetworkMapMembership.objects.create(**payload)
|
||||||
|
memberships_created += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
'replaced': bool(replace),
|
||||||
|
'cleared': cleared,
|
||||||
|
'importedContacts': contacts_created,
|
||||||
|
'contactsSkipped': contacts_skipped,
|
||||||
|
'importedRelations': relations_created,
|
||||||
|
'relationsSkipped': relations_skipped,
|
||||||
|
'importedMaps': maps_created,
|
||||||
|
'importedMemberships': memberships_created,
|
||||||
|
'membershipsSkipped': memberships_skipped,
|
||||||
|
'importedMapTypes': len(type_id_map),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dump_request(request):
|
||||||
|
if request.content_type and 'application/json' in request.content_type:
|
||||||
|
try:
|
||||||
|
body = request.body.decode('utf-8') if request.body else '{}'
|
||||||
|
return json.loads(body or '{}'), None
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return None, f'Некорректный JSON: {exc}'
|
||||||
|
dump_file = request.FILES.get('file')
|
||||||
|
if dump_file:
|
||||||
|
try:
|
||||||
|
return json.loads(dump_file.read().decode('utf-8')), None
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return None, f'Некорректный JSON в файле: {exc}'
|
||||||
|
return None, 'Передайте JSON-бэкап в теле запроса или файлом (поле file).'
|
||||||
@@ -4,4 +4,5 @@ from . import views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('import/', views.ImportContactsView.as_view(), name='import-contacts'),
|
path('import/', views.ImportContactsView.as_view(), name='import-contacts'),
|
||||||
|
path('export/', views.ExportDumpView.as_view(), name='export-dump'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,9 +4,38 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from core.access import use_jwt_auth
|
from core.access import use_jwt_auth
|
||||||
from core.drf_mixins import JwtAuthMixin
|
from core.drf_mixins import JwtAuthMixin
|
||||||
|
from .dump_services import build_owner_dump, import_owner_dump, is_data_dump, parse_dump_request
|
||||||
from .services import parse_upload_file, import_contacts_from_rows
|
from .services import parse_upload_file, import_contacts_from_rows
|
||||||
|
|
||||||
|
|
||||||
|
class ExportDumpView(JwtAuthMixin, APIView):
|
||||||
|
def get(self, request):
|
||||||
|
owner = request.user if use_jwt_auth() and request.user.is_authenticated else None
|
||||||
|
return Response(build_owner_dump(owner))
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
data, error = parse_dump_request(request)
|
||||||
|
if error:
|
||||||
|
return Response({'error': error}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if not is_data_dump(data):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Некорректный формат бэкапа: ожидаются массивы contacts и relations.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
replace = str(request.query_params.get('replace', '')).lower() in ('1', 'true', 'yes')
|
||||||
|
owner = request.user if use_jwt_auth() and request.user.is_authenticated else None
|
||||||
|
try:
|
||||||
|
summary = import_owner_dump(owner, data, replace=replace)
|
||||||
|
return Response(summary)
|
||||||
|
except ValueError as exc:
|
||||||
|
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except Exception as exc:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Ошибка импорта бэкапа: {exc}'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImportContactsView(JwtAuthMixin, APIView):
|
class ImportContactsView(JwtAuthMixin, APIView):
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
file = request.FILES.get('file')
|
file = request.FILES.get('file')
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from contacts.models import Contact, NetworkMap, NetworkMapMembership, NetworkMapType, Relation
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_export_dump_empty(api_client):
|
||||||
|
response = api_client.get('/api/v1/export/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data['version'] == 2
|
||||||
|
assert data['contacts'] == []
|
||||||
|
assert data['relations'] == []
|
||||||
|
assert isinstance(data['networkMapTypes'], list)
|
||||||
|
assert isinstance(data['networkMaps'], list)
|
||||||
|
assert isinstance(data['networkMapMemberships'], list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_export_dump_with_data(api_client, two_contacts, sample_relation):
|
||||||
|
response = api_client.get('/api/v1/export/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data['contacts']) == 2
|
||||||
|
assert len(data['relations']) == 1
|
||||||
|
assert data['relations'][0]['source'] == sample_relation.source_id
|
||||||
|
assert data['relations'][0]['target'] == sample_relation.target_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_import_dump_replace(api_client, two_contacts, sample_relation):
|
||||||
|
export_response = api_client.get('/api/v1/export/')
|
||||||
|
dump = export_response.json()
|
||||||
|
assert len(dump['contacts']) == 2
|
||||||
|
|
||||||
|
Contact.objects.all().delete()
|
||||||
|
assert Contact.objects.count() == 0
|
||||||
|
|
||||||
|
import_response = api_client.post('/api/v1/export/?replace=true', dump, format='json')
|
||||||
|
assert import_response.status_code == 200
|
||||||
|
result = import_response.json()
|
||||||
|
assert result['importedContacts'] == 2
|
||||||
|
assert result['importedRelations'] == 1
|
||||||
|
assert Contact.objects.count() == 2
|
||||||
|
assert Relation.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_import_dump_invalid(api_client):
|
||||||
|
response = api_client.post('/api/v1/export/', {'contacts': []}, format='json')
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'error' in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_import_dump_with_maps(api_client, db):
|
||||||
|
NetworkMapMembership.objects.all().delete()
|
||||||
|
NetworkMap.objects.all().delete()
|
||||||
|
NetworkMapType.objects.all().delete()
|
||||||
|
|
||||||
|
map_type = NetworkMapType.objects.create(
|
||||||
|
name='Базовый',
|
||||||
|
sectors=[{'id': 'work', 'label': 'Работа'}],
|
||||||
|
circles=[{'id': 'close', 'label': 'Близкий'}],
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
contact = Contact.objects.create(name='Алиса')
|
||||||
|
network_map = NetworkMap.objects.create(
|
||||||
|
name='Основная',
|
||||||
|
description='Тест',
|
||||||
|
map_type=map_type,
|
||||||
|
)
|
||||||
|
network_map.memberships.create(
|
||||||
|
contact=contact,
|
||||||
|
life_sphere='work',
|
||||||
|
network_circle='close',
|
||||||
|
importance=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
dump = api_client.get('/api/v1/export/').json()
|
||||||
|
assert len(dump['contacts']) == 1
|
||||||
|
assert len(dump['networkMaps']) == 1
|
||||||
|
assert len(dump['networkMapMemberships']) == 1
|
||||||
|
|
||||||
|
Contact.objects.all().delete()
|
||||||
|
NetworkMap.objects.all().delete()
|
||||||
|
NetworkMapType.objects.all().delete()
|
||||||
|
|
||||||
|
result = api_client.post('/api/v1/export/?replace=true', dump, format='json').json()
|
||||||
|
assert result['importedContacts'] == 1
|
||||||
|
assert result['importedMaps'] == 1
|
||||||
|
assert result['importedMemberships'] == 1
|
||||||
|
assert NetworkMapType.objects.count() == 1
|
||||||
|
assert NetworkMap.objects.count() == 1
|
||||||
@@ -11,21 +11,23 @@
|
|||||||
# sudo apache2ctl configtest && sudo systemctl reload apache2
|
# sudo apache2ctl configtest && sudo systemctl reload apache2
|
||||||
|
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
ServerName your-domain.com
|
ServerName social.deepfishing.ru
|
||||||
ServerAlias www.your-domain.com
|
|
||||||
|
|
||||||
ProxyPreserveHost On
|
ProxyPreserveHost On
|
||||||
RequestHeader set X-Forwarded-Proto "http"
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
|
RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
|
||||||
|
|
||||||
# Backend API (раскомментируйте при VITE_DATA_MODE=remote и profile with-backend)
|
# Вариант A (рекомендуется): весь трафик на frontend-контейнер.
|
||||||
# ProxyPass /api http://127.0.0.1:8000/api
|
# В remote-сборке frontend сам проксирует /api → backend.
|
||||||
# ProxyPassReverse /api http://127.0.0.1:8000/api
|
|
||||||
|
|
||||||
# Frontend SPA (nginx в контейнере sg_frontend)
|
|
||||||
ProxyPass / http://127.0.0.1:8080/
|
ProxyPass / http://127.0.0.1:8080/
|
||||||
ProxyPassReverse / http://127.0.0.1:8080/
|
ProxyPassReverse / http://127.0.0.1:8080/
|
||||||
|
|
||||||
|
# Вариант B: проксировать /api напрямую на backend (если frontend без nginx.remote.conf)
|
||||||
|
# ProxyPass /api http://127.0.0.1:8000/api
|
||||||
|
# ProxyPassReverse /api http://127.0.0.1:8000/api
|
||||||
|
# ProxyPass / http://127.0.0.1:8080/
|
||||||
|
# ProxyPassReverse / http://127.0.0.1:8080/
|
||||||
|
|
||||||
ErrorLog ${APACHE_LOG_DIR}/social-graph-error.log
|
ErrorLog ${APACHE_LOG_DIR}/social-graph-error.log
|
||||||
CustomLog ${APACHE_LOG_DIR}/social-graph-access.log combined
|
CustomLog ${APACHE_LOG_DIR}/social-graph-access.log combined
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
|
|||||||
@@ -116,12 +116,15 @@ cd /opt/social-graph
|
|||||||
|
|
||||||
### Remote (frontend + backend)
|
### Remote (frontend + backend)
|
||||||
|
|
||||||
|
> Обязательно: `VITE_DATA_MODE=remote` в `.env.prod` и профиль `with-backend` — иначе вход/регистрация не работают (ошибка 405).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build
|
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build
|
||||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
|
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build
|
||||||
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Команда поднимает **оба** контейнера. Frontend в remote-сборке проксирует `/api` → backend внутри Docker.
|
||||||
|
|
||||||
### Только frontend (local)
|
### Только frontend (local)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -320,8 +323,9 @@ sudo ufw enable
|
|||||||
|
|
||||||
| Симптом | Решение |
|
| Симптом | Решение |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
|
| **405 Not Allowed** при входе/регистрации | Запросы `/api` попали во frontend вместо backend. Пересоберите с `VITE_DATA_MODE=remote` (в образе включится `nginx.remote.conf`) **и** поднимите backend: `--profile with-backend` |
|
||||||
| 502 на https://social.deepfishing.ru | `docker ps`, логи `sg_frontend` / `sg_backend` |
|
| 502 на https://social.deepfishing.ru | `docker ps`, логи `sg_frontend` / `sg_backend` |
|
||||||
| Страница открывается, API 404 | В прокси включён `ProxyPass /api` → `:8000` |
|
| Страница открывается, API 404 | Backend не запущен или нет прокси `/api` |
|
||||||
| 401 / не пускает | `USE_JWT_AUTH=true` в `.env.prod`, пересобрать backend |
|
| 401 / не пускает | `USE_JWT_AUTH=true` в `.env.prod`, пересобрать backend |
|
||||||
| Данные не сохраняются между устройствами | `VITE_DATA_MODE=remote`, пересобрать frontend |
|
| Данные не сохраняются между устройствами | `VITE_DATA_MODE=remote`, пересобрать frontend |
|
||||||
| Белый экран | `docker logs sg_frontend`, пересборка с `--build` |
|
| Белый экран | `docker logs sg_frontend`, пересборка с `--build` |
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_BIND:-127.0.0.1}:${FRONTEND_PORT:-8080}:80"
|
- "${FRONTEND_BIND:-127.0.0.1}:${FRONTEND_PORT:-8080}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
|
required: false
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
profiles: ["with-backend"]
|
profiles: ["with-backend"]
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM nginx:1.27-alpine AS runtime
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
ARG VITE_DATA_MODE=local
|
||||||
|
COPY nginx.conf /tmp/nginx.local.conf
|
||||||
|
COPY nginx.remote.conf /tmp/nginx.remote.conf
|
||||||
|
RUN if [ "$VITE_DATA_MODE" = "remote" ]; then \
|
||||||
|
cp /tmp/nginx.remote.conf /etc/nginx/conf.d/default.conf; \
|
||||||
|
else \
|
||||||
|
cp /tmp/nginx.local.conf /etc/nginx/conf.d/default.conf; \
|
||||||
|
fi
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
|
# API → Django backend (контейнер backend в docker-compose)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,12 @@ import { generateId } from '../../lib/uuid'
|
|||||||
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
||||||
import { parseVcf } from '../../lib/import/vcard'
|
import { parseVcf } from '../../lib/import/vcard'
|
||||||
import { serializeContactsExport } from '../../lib/export/contacts'
|
import { serializeContactsExport } from '../../lib/export/contacts'
|
||||||
|
import { serializeRelationsExport } from '../../lib/export/relations'
|
||||||
|
import api from '../../api'
|
||||||
|
import { localContactRepository } from '../../infrastructure/repositories/contactRepository.local'
|
||||||
|
import { remoteContactRepository } from '../../infrastructure/repositories/contactRepository.remote'
|
||||||
|
import { localRelationRepository } from '../../infrastructure/repositories/relationRepository.local'
|
||||||
|
import { remoteRelationRepository } from '../../infrastructure/repositories/relationRepository.remote'
|
||||||
|
|
||||||
function isLikelyEmail(value) {
|
function isLikelyEmail(value) {
|
||||||
return value.includes('@') && value.includes('.')
|
return value.includes('@') && value.includes('.')
|
||||||
@@ -109,11 +115,137 @@ export async function importContactsFromFile(file) {
|
|||||||
return { total: rows.length, created, skipped, errors }
|
return { total: rows.length, created, skipped, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf'])
|
async function importRowsWithCreator(rows, createFn) {
|
||||||
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
|
const errors = []
|
||||||
|
for (let i = 0; i < rows.length; i += 1) {
|
||||||
|
const payload = toContactPayload(rows[i])
|
||||||
|
if (!payload.name) {
|
||||||
|
skipped += 1
|
||||||
|
errors.push(`Запись ${i + 1}: отсутствует имя контакта`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await createFn(payload)
|
||||||
|
created += 1
|
||||||
|
}
|
||||||
|
return { total: rows.length, created, skipped, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseContactRowsFromFile(file, { allowLocalDump = false } = {}) {
|
||||||
|
if (!file) throw new Error('Файл не выбран')
|
||||||
|
const name = file.name.toLowerCase()
|
||||||
|
const rawText = await readText(file)
|
||||||
|
|
||||||
|
if (name.endsWith('.csv')) {
|
||||||
|
return { kind: 'rows', rows: parseCsv(rawText) }
|
||||||
|
}
|
||||||
|
if (name.endsWith('.json')) {
|
||||||
|
const raw = JSON.parse(rawText)
|
||||||
|
if (isLocalDataDump(raw) || isEncryptedLocalDump(raw)) {
|
||||||
|
if (!allowLocalDump) {
|
||||||
|
throw new Error('Полный бэкап приложения импортируйте в разделе «Локальная база».')
|
||||||
|
}
|
||||||
|
return { kind: 'dump', file }
|
||||||
|
}
|
||||||
|
return { kind: 'rows', rows: normalizeRows(raw) }
|
||||||
|
}
|
||||||
|
if (name.endsWith('.vcf') || name.endsWith('.vcard')) {
|
||||||
|
const rows = parseVcf(rawText)
|
||||||
|
if (!rows.length) {
|
||||||
|
throw new Error('В файле vCard не найдено контактов.')
|
||||||
|
}
|
||||||
|
return { kind: 'rows', rows }
|
||||||
|
}
|
||||||
|
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf) файлы.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_CONTACT_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf'])
|
||||||
|
const ALLOWED_RELATION_EXPORT_FORMATS = new Set(['csv', 'json'])
|
||||||
|
|
||||||
|
function contactsExportResult(contacts, format) {
|
||||||
|
const normalized = String(format || 'csv').toLowerCase()
|
||||||
|
if (!ALLOWED_CONTACT_EXPORT_FORMATS.has(normalized)) {
|
||||||
|
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).')
|
||||||
|
}
|
||||||
|
const { filename, mime, content } = serializeContactsExport(contacts, normalized)
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
blob: new Blob([content], { type: mime }),
|
||||||
|
count: contacts.length,
|
||||||
|
format: normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationsExportResult(relations, format) {
|
||||||
|
const normalized = String(format || 'csv').toLowerCase()
|
||||||
|
if (!ALLOWED_RELATION_EXPORT_FORMATS.has(normalized)) {
|
||||||
|
throw new Error('Поддерживаются только CSV и JSON.')
|
||||||
|
}
|
||||||
|
const { filename, mime, content } = serializeRelationsExport(relations, normalized)
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
blob: new Blob([content], { type: mime }),
|
||||||
|
count: relations.length,
|
||||||
|
format: normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importContactsToLocalFromFile(file) {
|
||||||
|
const parsed = await parseContactRowsFromFile(file, { allowLocalDump: true })
|
||||||
|
if (parsed.kind === 'dump') {
|
||||||
|
const summary = await importLocalDump(parsed.file, '')
|
||||||
|
return {
|
||||||
|
total: summary.importedContacts + summary.importedRelations,
|
||||||
|
created: summary.importedContacts,
|
||||||
|
importedRelations: summary.importedRelations,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
isDump: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return importRowsWithCreator(parsed.rows, (payload) => localContactRepository.create(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importContactsToRemoteFromFile(file) {
|
||||||
|
await parseContactRowsFromFile(file, { allowLocalDump: false })
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const { data } = await api.post('/v1/import/', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 120000,
|
||||||
|
})
|
||||||
|
if (data?.error) throw new Error(data.error)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportContactsFromLocal({ format = 'csv' } = {}) {
|
||||||
|
const contacts = await localContactRepository.list()
|
||||||
|
if (!contacts.length) throw new Error('Нет контактов в локальной базе.')
|
||||||
|
return contactsExportResult(contacts, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportContactsFromRemote({ format = 'csv' } = {}) {
|
||||||
|
const contacts = await remoteContactRepository.list()
|
||||||
|
if (!contacts.length) throw new Error('Нет контактов на сервере.')
|
||||||
|
return contactsExportResult(contacts, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportRelationsFromLocal({ format = 'csv' } = {}) {
|
||||||
|
const relations = await localRelationRepository.list()
|
||||||
|
if (!relations.length) throw new Error('Нет связей в локальной базе.')
|
||||||
|
return relationsExportResult(relations, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportRelationsFromRemote({ format = 'csv' } = {}) {
|
||||||
|
const relations = await remoteRelationRepository.list()
|
||||||
|
if (!relations.length) throw new Error('Нет связей на сервере.')
|
||||||
|
return relationsExportResult(relations, format)
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportContacts({ format = 'csv' } = {}) {
|
export async function exportContacts({ format = 'csv' } = {}) {
|
||||||
const normalized = String(format || 'csv').toLowerCase()
|
const normalized = String(format || 'csv').toLowerCase()
|
||||||
if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
|
if (!ALLOWED_CONTACT_EXPORT_FORMATS.has(normalized)) {
|
||||||
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).')
|
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +429,38 @@ function migrateV1ToV2(dump) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportRemoteData() {
|
||||||
|
const { data } = await api.get('/v1/export/', { timeout: 120000 })
|
||||||
|
return {
|
||||||
|
filename: `social-graph-export-${Date.now()}.json`,
|
||||||
|
blob: new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseDumpFile(file, passphrase = '') {
|
||||||
|
const raw = JSON.parse(await file.text())
|
||||||
|
if (isEncryptedLocalDump(raw)) {
|
||||||
|
if (!passphrase) {
|
||||||
|
throw new Error('Для зашифрованного бэкапа укажите пароль.')
|
||||||
|
}
|
||||||
|
return decryptPayload(raw, passphrase)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importRemoteDump(file, { replace = true, passphrase = '' } = {}) {
|
||||||
|
const dump = await parseDumpFile(file, passphrase)
|
||||||
|
if (!isLocalDataDump(dump)) {
|
||||||
|
throw new Error('Некорректный формат бэкапа: ожидаются массивы contacts и relations.')
|
||||||
|
}
|
||||||
|
const { data } = await api.post('/v1/export/', dump, {
|
||||||
|
params: { replace: replace ? 'true' : 'false' },
|
||||||
|
timeout: 300000,
|
||||||
|
})
|
||||||
|
if (data?.error) throw new Error(data.error)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportLocalData({ passphrase = '' } = {}) {
|
export async function exportLocalData({ passphrase = '' } = {}) {
|
||||||
const payload = {
|
const payload = {
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|||||||
@@ -43,6 +43,18 @@
|
|||||||
<p v-if="selectedContact" class="selected-summary">
|
<p v-if="selectedContact" class="selected-summary">
|
||||||
Выбран: <strong>{{ selectedContact.name }}</strong>
|
Выбран: <strong>{{ selectedContact.name }}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<RelationLinkFields
|
||||||
|
v-if="availableLinkTargets.length"
|
||||||
|
ref="relationLinkRef"
|
||||||
|
:options="availableLinkTargets"
|
||||||
|
:initial-target-id="initialLinkToId"
|
||||||
|
:conflict-mode="conflictMode"
|
||||||
|
label="Связать с участником карты"
|
||||||
|
placeholder="Выберите участника..."
|
||||||
|
hint="Необязательно — при добавлении на карту будет создана связь."
|
||||||
|
/>
|
||||||
|
|
||||||
<p v-if="error" class="form-error">{{ error }}</p>
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -65,16 +77,21 @@
|
|||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { listContacts } from '../application/usecases/contacts'
|
import { listContacts } from '../application/usecases/contacts'
|
||||||
import { normalizeApiError } from '../lib/api/errors'
|
import { normalizeApiError } from '../lib/api/errors'
|
||||||
|
import RelationLinkFields from './RelationLinkFields.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
memberContactIds: { type: Array, default: () => [] },
|
memberContactIds: { type: Array, default: () => [] },
|
||||||
|
linkTargets: { type: Array, default: () => [] },
|
||||||
|
initialLinkToId: { type: [String, Number], default: '' },
|
||||||
|
conflictMode: { type: Boolean, default: false },
|
||||||
onAdd: { type: Function, required: true },
|
onAdd: { type: Function, required: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
const searchInputRef = ref(null)
|
const searchInputRef = ref(null)
|
||||||
|
const relationLinkRef = ref(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const searchResults = ref([])
|
const searchResults = ref([])
|
||||||
const selectedContactId = ref('')
|
const selectedContactId = ref('')
|
||||||
@@ -91,6 +108,12 @@ const selectedContact = computed(() =>
|
|||||||
searchResults.value.find((c) => String(c.id) === String(selectedContactId.value)) || null
|
searchResults.value.find((c) => String(c.id) === String(selectedContactId.value)) || null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const availableLinkTargets = computed(() => {
|
||||||
|
const selectedId = String(selectedContactId.value)
|
||||||
|
if (!selectedId) return props.linkTargets
|
||||||
|
return props.linkTargets.filter((t) => String(t.value) !== selectedId)
|
||||||
|
})
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
searchResults.value = []
|
searchResults.value = []
|
||||||
@@ -98,6 +121,7 @@ function resetState() {
|
|||||||
searching.value = false
|
searching.value = false
|
||||||
saving.value = false
|
saving.value = false
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
relationLinkRef.value?.reset?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClose() {
|
function onClose() {
|
||||||
@@ -174,7 +198,8 @@ async function submit() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
await props.onAdd(selectedContactId.value)
|
const relationLink = relationLinkRef.value?.getRelationLink?.() ?? null
|
||||||
|
await props.onAdd(selectedContactId.value, relationLink)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = normalizeApiError(e).message
|
error.value = normalizeApiError(e).message
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -45,6 +45,13 @@
|
|||||||
<label>Заметки</label>
|
<label>Заметки</label>
|
||||||
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<RelationLinkFields
|
||||||
|
v-if="showRelationLink"
|
||||||
|
ref="relationLinkRef"
|
||||||
|
:options="linkToOptions"
|
||||||
|
:initial-target-id="initialLinkToId"
|
||||||
|
:conflict-mode="conflictMode"
|
||||||
|
/>
|
||||||
<component
|
<component
|
||||||
:is="Ext"
|
:is="Ext"
|
||||||
v-for="(Ext, index) in contactFormExtensions"
|
v-for="(Ext, index) in contactFormExtensions"
|
||||||
@@ -74,17 +81,23 @@ import { reactive, ref, watch, computed, onMounted } from 'vue'
|
|||||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||||
import { listMembershipsByContact } from '../application/usecases/networkMaps'
|
import { listMembershipsByContact } from '../application/usecases/networkMaps'
|
||||||
import { getContactFormExtensions } from '../core/pluginRegistry'
|
import { getContactFormExtensions } from '../core/pluginRegistry'
|
||||||
|
import RelationLinkFields from './RelationLinkFields.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initial: { type: Object, default: () => ({}) },
|
initial: { type: Object, default: () => ({}) },
|
||||||
initialMapIds: { type: Array, default: null },
|
initialMapIds: { type: Array, default: null },
|
||||||
deletable: { type: Boolean, default: false },
|
deletable: { type: Boolean, default: false },
|
||||||
|
showRelationLink: { type: Boolean, default: false },
|
||||||
|
linkToOptions: { type: Array, default: () => [] },
|
||||||
|
initialLinkToId: { type: [String, Number], default: '' },
|
||||||
|
conflictMode: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['submit', 'cancel', 'delete'])
|
const emit = defineEmits(['submit', 'cancel', 'delete'])
|
||||||
|
|
||||||
const mapsStore = useNetworkMapsStore()
|
const mapsStore = useNetworkMapsStore()
|
||||||
const contactFormExtensions = getContactFormExtensions()
|
const contactFormExtensions = getContactFormExtensions()
|
||||||
const pluginTags = ref([])
|
const pluginTags = ref([])
|
||||||
|
const relationLinkRef = ref(null)
|
||||||
|
|
||||||
const showDelete = computed(() => {
|
const showDelete = computed(() => {
|
||||||
if (props.deletable) return true
|
if (props.deletable) return true
|
||||||
@@ -150,7 +163,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
function onSubmit() {
|
function onSubmit() {
|
||||||
const { mapIds, ...contactData } = form
|
const { mapIds, ...contactData } = form
|
||||||
emit('submit', contactData, mapIds, { tags: [...pluginTags.value] })
|
const relationLink = props.showRelationLink
|
||||||
|
? relationLinkRef.value?.getRelationLink?.() ?? null
|
||||||
|
: null
|
||||||
|
emit('submit', contactData, mapIds, { tags: [...pluginTags.value] }, relationLink)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contact-relations-section" :class="{ 'contact-relations-section--standalone': standalone }">
|
||||||
|
<div class="contact-relations-section__header">
|
||||||
|
<h4>Связи ({{ contactRelations.length }})</h4>
|
||||||
|
<button class="btn btn-primary btn-sm" type="button" @click="showAddRelation = true">
|
||||||
|
+ Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="contactRelations.length === 0" class="contact-relations-empty text-muted">
|
||||||
|
Нет связей с другими контактами.
|
||||||
|
</div>
|
||||||
|
<div v-else class="relations-list">
|
||||||
|
<div
|
||||||
|
v-for="rel in contactRelations"
|
||||||
|
:key="rel.id"
|
||||||
|
class="relation-row"
|
||||||
|
@click="openEditRelation(rel)"
|
||||||
|
>
|
||||||
|
<div class="relation-row__body">
|
||||||
|
<div class="relation-row__main">
|
||||||
|
<span class="relation-row__name">{{ otherContactName(rel) }}</span>
|
||||||
|
<span :class="`badge badge-${rel.relation_type}`">{{ relLabel(rel.relation_type) }}</span>
|
||||||
|
<span class="relation-row__intensity">{{ intensityLabel(rel.interaction_intensity) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="rel.description" class="relation-row__desc">{{ 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditRelationModal
|
||||||
|
:open="editRelationOpen"
|
||||||
|
:relation="editRelationTarget"
|
||||||
|
@close="closeEditRelation"
|
||||||
|
@updated="closeEditRelation"
|
||||||
|
@deleted="closeEditRelation"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="showAddRelation" class="modal-overlay nested-overlay" @click.self="showAddRelation = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Добавить связь</h3>
|
||||||
|
<button class="btn btn-secondary btn-sm" type="button" @click="showAddRelation = false">✕</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="relError" class="alert alert-error">{{ relError }}</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>С кем связать</label>
|
||||||
|
<SearchableSelect
|
||||||
|
v-model="newRel.targetId"
|
||||||
|
:options="contactSelectOptions"
|
||||||
|
placeholder="Введите имя контакта..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Тип связи</label>
|
||||||
|
<RelationTypeSelect v-model="newRel.type" :options="relationTypes" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Интенсивность общения</label>
|
||||||
|
<InteractionIntensitySelect v-model="newRel.interaction_intensity" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Описание (необязательно)</label>
|
||||||
|
<input v-model="newRel.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" type="button" @click="showAddRelation = false">Отмена</button>
|
||||||
|
<button class="btn btn-primary" type="button" :disabled="!newRel.targetId" @click="addRelation">
|
||||||
|
Создать связь
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useContactsStore } from '../stores/contacts'
|
||||||
|
import EditRelationModal from './EditRelationModal.vue'
|
||||||
|
import SearchableSelect from './SearchableSelect.vue'
|
||||||
|
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
|
||||||
|
import RelationTypeSelect from './RelationTypeSelect.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
contactId: { type: [String, Number], required: true },
|
||||||
|
standalone: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useContactsStore()
|
||||||
|
const showAddRelation = ref(false)
|
||||||
|
const editRelationOpen = ref(false)
|
||||||
|
const editRelationTarget = ref(null)
|
||||||
|
const relationTypes = ref([])
|
||||||
|
const interactionIntensities = ref([])
|
||||||
|
const relError = ref('')
|
||||||
|
const newRel = ref({
|
||||||
|
targetId: '',
|
||||||
|
type: 'acquaintance',
|
||||||
|
description: '',
|
||||||
|
interaction_intensity: 'intense',
|
||||||
|
})
|
||||||
|
|
||||||
|
const contactRelations = computed(() =>
|
||||||
|
store.relations.filter(
|
||||||
|
(r) => String(r.source) === String(props.contactId) || String(r.target) === String(props.contactId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const otherContacts = computed(() =>
|
||||||
|
store.contacts
|
||||||
|
.filter((c) => String(c.id) !== String(props.contactId))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const contactSelectOptions = computed(() =>
|
||||||
|
otherContacts.value.map((c) => ({ value: c.id, label: c.name }))
|
||||||
|
)
|
||||||
|
|
||||||
|
function relLabel(type) {
|
||||||
|
return relationTypes.value.find((r) => r.value === type)?.label || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function intensityLabel(v) {
|
||||||
|
return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function otherContactName(rel) {
|
||||||
|
const cid = String(props.contactId)
|
||||||
|
return String(rel.source) === cid ? rel.target_name : rel.source_name
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditRelation(rel) {
|
||||||
|
editRelationTarget.value = rel
|
||||||
|
editRelationOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditRelation() {
|
||||||
|
editRelationOpen.value = false
|
||||||
|
editRelationTarget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRelation() {
|
||||||
|
relError.value = ''
|
||||||
|
try {
|
||||||
|
await store.createRelation({
|
||||||
|
source: props.contactId,
|
||||||
|
target: newRel.value.targetId,
|
||||||
|
relation_type: newRel.value.type,
|
||||||
|
description: newRel.value.description,
|
||||||
|
interaction_intensity: newRel.value.interaction_intensity,
|
||||||
|
})
|
||||||
|
showAddRelation.value = false
|
||||||
|
newRel.value = {
|
||||||
|
targetId: '',
|
||||||
|
type: 'acquaintance',
|
||||||
|
description: '',
|
||||||
|
interaction_intensity: 'intense',
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e.response?.data
|
||||||
|
relError.value = typeof msg === 'object' ? JSON.stringify(msg) : String(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRelation(id) {
|
||||||
|
if (!window.confirm('Удалить связь?')) return
|
||||||
|
await store.deleteRelation(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!store.relations.length) {
|
||||||
|
await store.fetchRelations()
|
||||||
|
}
|
||||||
|
if (!store.contacts.length) {
|
||||||
|
await store.fetchContacts()
|
||||||
|
}
|
||||||
|
const [rt, intensities] = await Promise.all([
|
||||||
|
store.fetchRelationTypes(),
|
||||||
|
store.fetchNetworkMapChoices(),
|
||||||
|
])
|
||||||
|
relationTypes.value = rt
|
||||||
|
interactionIntensities.value = intensities?.interaction_intensities || []
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.contact-relations-section:not(.contact-relations-section--standalone) {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.contact-relations-section--standalone .contact-relations-section__header h4 {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.contact-relations-section__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.contact-relations-section__header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.contact-relations-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.relations-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.relation-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.relation-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.relation-row:hover {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
margin: 0 -8px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.relation-row__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.relation-row__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.relation-row__name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.relation-row__intensity {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.relation-row__desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.relation-row__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nested-overlay {
|
||||||
|
z-index: 950;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
<ContactForm
|
<ContactForm
|
||||||
:initial="{}"
|
:initial="{}"
|
||||||
:initial-map-ids="initialMapIds"
|
:initial-map-ids="initialMapIds"
|
||||||
|
:show-relation-link="showRelationLink"
|
||||||
|
:link-to-options="linkToOptions"
|
||||||
|
:initial-link-to-id="initialLinkToId"
|
||||||
|
:conflict-mode="conflictMode"
|
||||||
@submit="onSubmit"
|
@submit="onSubmit"
|
||||||
@cancel="onCancel"
|
@cancel="onCancel"
|
||||||
/>
|
/>
|
||||||
@@ -21,6 +25,10 @@ import ContactForm from './ContactForm.vue'
|
|||||||
defineProps({
|
defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
initialMapIds: { type: Array, default: () => [] },
|
initialMapIds: { type: Array, default: () => [] },
|
||||||
|
showRelationLink: { type: Boolean, default: false },
|
||||||
|
linkToOptions: { type: Array, default: () => [] },
|
||||||
|
initialLinkToId: { type: [String, Number], default: '' },
|
||||||
|
conflictMode: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'created'])
|
const emit = defineEmits(['close', 'created'])
|
||||||
@@ -29,7 +37,7 @@ function onCancel() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit(contactData, mapIds, pluginPayload) {
|
function onSubmit(contactData, mapIds, pluginPayload, relationLink) {
|
||||||
emit('created', contactData, mapIds, pluginPayload)
|
emit('created', contactData, mapIds, pluginPayload, relationLink)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,8 +14,15 @@
|
|||||||
@click.stop
|
@click.stop
|
||||||
@contextmenu.prevent
|
@contextmenu.prevent
|
||||||
>
|
>
|
||||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onCreateContact">
|
<button
|
||||||
Добавить контакт
|
v-for="item in actions"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="graph-node-menu__item"
|
||||||
|
role="menuitem"
|
||||||
|
@click="onSelect(item.id)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -28,16 +35,21 @@ const props = defineProps({
|
|||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
x: { type: Number, default: 0 },
|
x: { type: Number, default: 0 },
|
||||||
y: { type: Number, default: 0 },
|
y: { type: Number, default: 0 },
|
||||||
|
actions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [{ id: 'create-contact', label: 'Добавить контакт' }],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'create-contact'])
|
const emit = defineEmits(['close', 'create-contact', 'select'])
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCreateContact() {
|
function onSelect(id) {
|
||||||
emit('create-contact')
|
emit('select', id)
|
||||||
|
if (id === 'create-contact') emit('create-contact')
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,18 +14,15 @@
|
|||||||
@click.stop
|
@click.stop
|
||||||
@contextmenu.prevent
|
@contextmenu.prevent
|
||||||
>
|
>
|
||||||
<div class="graph-node-menu__title">{{ edgeTitle }}</div>
|
|
||||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onEdit">
|
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onEdit">
|
||||||
Редактировать связь
|
Редактировать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
import { onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { RELATION_TYPES } from '../domain/networkChoices'
|
|
||||||
import { CONFLICT_RELATION_TYPES } from '../domain/conflictology'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
@@ -36,17 +33,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['close', 'edit'])
|
const emit = defineEmits(['close', 'edit'])
|
||||||
|
|
||||||
const typeLabels = Object.fromEntries([
|
|
||||||
...RELATION_TYPES,
|
|
||||||
...CONFLICT_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() {
|
function close() {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
@@ -90,18 +76,6 @@ onUnmounted(() => {
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
padding: 6px 0;
|
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 {
|
.graph-node-menu__item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay" @click.self="close">
|
||||||
|
<div class="modal graph-filters-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Фильтры</h3>
|
||||||
|
<button class="btn btn-secondary btn-sm" type="button" @click="close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="graph-filters-hint text-muted">
|
||||||
|
Ctrl+клик (⌘+клик) по двум узлам — создать связь.
|
||||||
|
<span v-if="linkSelectionCount === 1">
|
||||||
|
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Типы связей</label>
|
||||||
|
<RelationTypeFilters
|
||||||
|
:relation-types="relationTypes"
|
||||||
|
:active-values="activeFilters"
|
||||||
|
@toggle="$emit('toggle', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="toolbarActions.length" class="form-group">
|
||||||
|
<label>Дополнительно</label>
|
||||||
|
<div class="graph-filters-actions">
|
||||||
|
<button
|
||||||
|
v-for="action in toolbarActions"
|
||||||
|
:key="action.id"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
@click="onToolbarAction(action)"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import RelationTypeFilters from './RelationTypeFilters.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
relationTypes: { type: Array, default: () => [] },
|
||||||
|
activeFilters: { type: Array, default: () => [] },
|
||||||
|
linkSelectionCount: { type: Number, default: 0 },
|
||||||
|
linkSelection: { type: Array, default: () => [] },
|
||||||
|
toolbarActions: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'toggle', 'toolbar-action'])
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToolbarAction(action) {
|
||||||
|
emit('toolbar-action', action)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.graph-filters-modal {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
.graph-filters-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.graph-filters-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
<div class="graph-view-header">
|
<div class="graph-view-header">
|
||||||
<h2>{{ title }}</h2>
|
<h2>{{ title }}</h2>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn btn-secondary btn-sm" @click="$emit('reset')">
|
<slot name="actions" />
|
||||||
|
<button v-if="showReset" class="btn btn-secondary btn-sm" @click="$emit('reset')">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||||
<path d="M3 3v5h5"/>
|
<path d="M3 3v5h5"/>
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
title: { type: String, default: 'Граф связей' },
|
title: { type: String, default: 'Граф связей' },
|
||||||
|
showReset: { type: Boolean, default: true },
|
||||||
resetLabel: { type: String, default: 'Сбросить вид' },
|
resetLabel: { type: String, default: 'Сбросить вид' },
|
||||||
showPhysicsToggle: { type: Boolean, default: false },
|
showPhysicsToggle: { type: Boolean, default: false },
|
||||||
physicsEnabled: { type: Boolean, default: true },
|
physicsEnabled: { type: Boolean, default: true },
|
||||||
|
|||||||
@@ -14,24 +14,20 @@
|
|||||||
@click.stop
|
@click.stop
|
||||||
@contextmenu.prevent
|
@contextmenu.prevent
|
||||||
>
|
>
|
||||||
<div class="graph-node-menu__title">{{ nodeLabel }}</div>
|
|
||||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onInfo">
|
|
||||||
Информация
|
|
||||||
</button>
|
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="`/contacts/${node.id}`"
|
:to="{ path: `/contacts/${node.id}`, query: { edit: '1' } }"
|
||||||
class="graph-node-menu__item graph-node-menu__link"
|
class="graph-node-menu__item graph-node-menu__link"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
Открыть карточку
|
Редактировать контакт
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
import { onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -41,19 +37,12 @@ const props = defineProps({
|
|||||||
y: { type: Number, default: 0 },
|
y: { type: Number, default: 0 },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'info'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
const nodeLabel = computed(() => props.node?.label || props.node?.name || '')
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInfo() {
|
|
||||||
emit('info', props.node)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown(event) {
|
function onKeyDown(event) {
|
||||||
if (event.key === 'Escape' && props.open) close()
|
if (event.key === 'Escape' && props.open) close()
|
||||||
}
|
}
|
||||||
@@ -88,18 +77,6 @@ onUnmounted(() => {
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
padding: 6px 0;
|
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 {
|
.graph-node-menu__item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="map-switcher">
|
<div class="map-switcher">
|
||||||
<label class="map-switcher-label">Карта</label>
|
<label class="map-switcher-label">Карта</label>
|
||||||
<select
|
<div class="map-switcher-controls">
|
||||||
class="form-control map-switcher-select"
|
<select
|
||||||
:value="modelValue"
|
class="form-control map-switcher-select"
|
||||||
@change="onSelect"
|
:value="modelValue"
|
||||||
>
|
@change="onSelect"
|
||||||
<option v-for="map in maps" :key="map.id" :value="String(map.id)">
|
>
|
||||||
{{ map.name }}
|
<option v-for="map in maps" :key="map.id" :value="String(map.id)">
|
||||||
</option>
|
{{ map.name }}
|
||||||
</select>
|
</option>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" @click="$emit('create')">
|
</select>
|
||||||
+ Новая
|
<button type="button" class="btn btn-secondary btn-sm" @click="$emit('create')">
|
||||||
</button>
|
+ Новая
|
||||||
<button
|
</button>
|
||||||
v-if="modelValue"
|
<button
|
||||||
type="button"
|
v-if="modelValue"
|
||||||
class="btn btn-secondary btn-sm"
|
type="button"
|
||||||
title="Настройки карты"
|
class="btn btn-secondary btn-sm map-switcher-settings"
|
||||||
@click="$emit('manage', modelValue)"
|
title="Настройки карты"
|
||||||
>
|
@click="$emit('manage', modelValue)"
|
||||||
⚙
|
>
|
||||||
</button>
|
⚙
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -42,15 +44,28 @@ function onSelect(event) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
.map-switcher-label {
|
.map-switcher-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.map-switcher-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
.map-switcher-select {
|
.map-switcher-select {
|
||||||
min-width: 160px;
|
min-width: 140px;
|
||||||
max-width: 240px;
|
max-width: 220px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.map-switcher-settings {
|
||||||
|
min-width: 34px;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,16 +18,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="network-map-actions">
|
<div class="network-map-actions">
|
||||||
<slot name="toolbar" />
|
<slot name="toolbar" />
|
||||||
<button class="btn btn-secondary btn-sm" @click="$emit('fit')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
|
||||||
</svg>
|
|
||||||
По центру
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="!collapsed" class="network-map-legend">
|
<div v-show="!collapsed && showLegend" class="network-map-legend">
|
||||||
<slot name="legend" />
|
<slot name="legend" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,25 +35,28 @@ defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
showLegend: { type: Boolean, default: true },
|
||||||
})
|
})
|
||||||
defineEmits(['toggle-collapse', 'fit'])
|
defineEmits(['toggle-collapse'])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.network-map-top-panel {
|
.network-map-top-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
.network-map-top-panel.collapsed {
|
.network-map-top-panel.collapsed {
|
||||||
min-height: 0;
|
min-height: 28px;
|
||||||
border-bottom: none;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
.panel-toggle-btn {
|
.panel-toggle-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -11px;
|
bottom: 4px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
z-index: 4;
|
z-index: 6;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin-left: -14px;
|
margin-left: -14px;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relation-link-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ label }}</label>
|
||||||
|
<SearchableSelect
|
||||||
|
v-model="targetId"
|
||||||
|
:options="options"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
/>
|
||||||
|
<p v-if="hint" class="field-hint text-muted">{{ hint }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="targetId">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ conflictMode ? 'Тип связи в конфликте' : 'Тип связи' }}</label>
|
||||||
|
<RelationTypeSelect
|
||||||
|
v-model="relationType"
|
||||||
|
:options="conflictMode ? conflictTypes : undefined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="!conflictMode" class="form-group">
|
||||||
|
<label>Интенсивность общения</label>
|
||||||
|
<InteractionIntensitySelect v-model="intensity" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import SearchableSelect from './SearchableSelect.vue'
|
||||||
|
import RelationTypeSelect from './RelationTypeSelect.vue'
|
||||||
|
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
|
||||||
|
import { CONFLICT_RELATION_TYPES } from '../domain/conflictology'
|
||||||
|
|
||||||
|
const conflictTypes = CONFLICT_RELATION_TYPES
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
initialTargetId: { type: [String, Number], default: '' },
|
||||||
|
conflictMode: { type: Boolean, default: false },
|
||||||
|
label: { type: String, default: 'Связать с контактом' },
|
||||||
|
placeholder: { type: String, default: 'Выберите контакт...' },
|
||||||
|
hint: {
|
||||||
|
type: String,
|
||||||
|
default: 'Необязательно — контакт появится на графе и будет связан с выбранным.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const targetId = ref('')
|
||||||
|
const relationType = ref('acquaintance')
|
||||||
|
const intensity = ref('intense')
|
||||||
|
|
||||||
|
const relationLink = computed(() => {
|
||||||
|
if (!targetId.value) return null
|
||||||
|
return {
|
||||||
|
targetId: targetId.value,
|
||||||
|
type: relationType.value,
|
||||||
|
intensity: intensity.value,
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
targetId.value = props.initialTargetId ? String(props.initialTargetId) : ''
|
||||||
|
relationType.value = props.conflictMode ? 'conflict_neutral' : 'acquaintance'
|
||||||
|
intensity.value = 'intense'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelationLink() {
|
||||||
|
return relationLink.value
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.initialTargetId, props.conflictMode],
|
||||||
|
() => reset(),
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({ getRelationLink, reset })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.relation-link-fields + .relation-link-fields {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="export-row">
|
||||||
|
<div class="form-group export-format-field">
|
||||||
|
<label :for="inputId">Формат файла</label>
|
||||||
|
<select :id="inputId" :value="modelValue" class="form-control" @change="onChange">
|
||||||
|
<option value="csv">CSV (.csv)</option>
|
||||||
|
<option value="json">JSON (.json)</option>
|
||||||
|
<option v-if="variant === 'contacts'" value="vcf">vCard (.vcf)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled || exporting"
|
||||||
|
@click="$emit('export')"
|
||||||
|
>
|
||||||
|
{{ exporting ? 'Экспорт...' : buttonLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
|
||||||
|
<span v-if="result.error">{{ result.error }}</span>
|
||||||
|
<span v-else>
|
||||||
|
Экспортировано {{ entityLabel }}: <strong>{{ result.count }}</strong>
|
||||||
|
({{ result.formatLabel }}).
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, useId } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: 'csv' },
|
||||||
|
exporting: { type: Boolean, default: false },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
result: { type: Object, default: null },
|
||||||
|
buttonLabel: { type: String, default: 'Экспортировать' },
|
||||||
|
variant: { type: String, default: 'contacts' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'export'])
|
||||||
|
const inputId = useId()
|
||||||
|
|
||||||
|
const entityLabel = computed(() => (props.variant === 'relations' ? 'связей' : 'контактов'))
|
||||||
|
|
||||||
|
function onChange(event) {
|
||||||
|
emit('update:modelValue', event.target.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.export-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.export-format-field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div class="import-file-block" :class="{ 'import-file-block--disabled': disabled }">
|
||||||
|
<h4 class="subsection-title">Импорт файла</h4>
|
||||||
|
<p class="text-muted import-hint">
|
||||||
|
CSV, JSON или vCard (.vcf).
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="drop-zone"
|
||||||
|
:class="{ 'drag-over': isDragging, 'drop-zone--disabled': disabled }"
|
||||||
|
@dragover.prevent="onDragOver"
|
||||||
|
@dragleave="isDragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
@click="openPicker"
|
||||||
|
>
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="drop-zone__icon">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<div class="drop-zone__label">
|
||||||
|
{{ file?.name || 'Перетащите файл или нажмите для выбора' }}
|
||||||
|
</div>
|
||||||
|
<input ref="fileInput" type="file" accept=".csv,.json,.vcf,.vcard" style="display:none" @change="onFileSelect" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImportResultAlert :result="result" />
|
||||||
|
|
||||||
|
<div class="import-file-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled || !file || importing"
|
||||||
|
@click="$emit('import')"
|
||||||
|
>
|
||||||
|
<svg v-if="importing" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin-icon">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.22-8.56"/>
|
||||||
|
</svg>
|
||||||
|
{{ importing ? 'Импорт...' : importLabel }}
|
||||||
|
</button>
|
||||||
|
<button v-if="file" class="btn btn-secondary" type="button" :disabled="importing" @click="clear">
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ImportResultAlert from './ImportResultAlert.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
file: { type: Object, default: null },
|
||||||
|
importing: { type: Boolean, default: false },
|
||||||
|
result: { type: Object, default: null },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
importLabel: { type: String, default: 'Импортировать' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:file', 'import', 'reset'])
|
||||||
|
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
if (props.disabled) return
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver() {
|
||||||
|
if (!props.disabled) isDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelect(e) {
|
||||||
|
emit('update:file', e.target.files[0] || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e) {
|
||||||
|
isDragging.value = false
|
||||||
|
if (props.disabled) return
|
||||||
|
const picked = e.dataTransfer.files[0]
|
||||||
|
if (picked) {
|
||||||
|
emit('update:file', picked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
emit('update:file', null)
|
||||||
|
emit('reset')
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.subsection-title {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.import-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.drop-zone:hover,
|
||||||
|
.drag-over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
.drop-zone--disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.drop-zone__icon {
|
||||||
|
opacity: 0.4;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.drop-zone__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.import-file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.spin-icon {
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
vertical-align: -2px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:16px;">
|
||||||
|
<span v-if="result.error">{{ result.error }}</span>
|
||||||
|
<span v-else>
|
||||||
|
В файле: <strong>{{ result.total ?? result.created + result.skipped }}</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>
|
||||||
|
</div>
|
||||||
|
<div v-if="result?.errors?.length" style="margin-top:8px;">
|
||||||
|
<div v-for="e in result.errors" :key="e" class="text-muted" style="font-size:12px;">{{ e }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
result: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { CONFLICT_CENTER_NODE_ID } from '../domain/conflictology'
|
||||||
|
|
||||||
export function useGraphNodeContextMenu() {
|
export function useGraphNodeContextMenu() {
|
||||||
const contextMenuOpen = ref(false)
|
const contextMenuOpen = ref(false)
|
||||||
@@ -63,14 +64,39 @@ export function useGraphNodeContextMenu() {
|
|||||||
canvasContextMenuOpen.value = false
|
canvasContextMenuOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canvasPointerFromEvent(network, domEvent) {
|
||||||
|
const canvas = network.canvas?.frame?.canvas
|
||||||
|
if (!canvas || !domEvent) return null
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: domEvent.clientX - rect.left,
|
||||||
|
y: domEvent.clientY - rect.top,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNodeAtPointer(network, domEvent, getNodes) {
|
||||||
|
if (!domEvent || typeof network.getNodeAt !== 'function') return null
|
||||||
|
const pointer = canvasPointerFromEvent(network, domEvent)
|
||||||
|
if (!pointer) return null
|
||||||
|
let nodeId = null
|
||||||
|
try {
|
||||||
|
nodeId = network.getNodeAt(pointer)
|
||||||
|
} catch {
|
||||||
|
nodeId = null
|
||||||
|
}
|
||||||
|
if (!nodeId) return null
|
||||||
|
return getNodes().find((n) => String(n.id) === String(nodeId)) || null
|
||||||
|
}
|
||||||
|
|
||||||
function resolveEdgeAtPointer(network, domEvent, getEdges) {
|
function resolveEdgeAtPointer(network, domEvent, getEdges) {
|
||||||
|
if (!domEvent || typeof network.getEdgeAt !== 'function') return null
|
||||||
|
const pointer = canvasPointerFromEvent(network, domEvent)
|
||||||
|
if (!pointer) return null
|
||||||
let edgeId = null
|
let edgeId = null
|
||||||
if (domEvent && typeof network.getPointer === 'function' && typeof network.getEdgeAt === 'function') {
|
try {
|
||||||
try {
|
edgeId = network.getEdgeAt(pointer)
|
||||||
edgeId = network.getEdgeAt(network.getPointer(domEvent))
|
} catch {
|
||||||
} catch {
|
edgeId = null
|
||||||
edgeId = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!edgeId) return null
|
if (!edgeId) return null
|
||||||
return getEdges().find((e) => String(e.id) === String(edgeId)) || null
|
return getEdges().find((e) => String(e.id) === String(edgeId)) || null
|
||||||
@@ -81,28 +107,26 @@ export function useGraphNodeContextMenu() {
|
|||||||
const domEvent = params.event?.srcEvent || params.event
|
const domEvent = params.event?.srcEvent || params.event
|
||||||
domEvent?.preventDefault?.()
|
domEvent?.preventDefault?.()
|
||||||
|
|
||||||
let edge = null
|
let node = resolveNodeAtPointer(network, domEvent, getNodes)
|
||||||
if (params.edges?.length > 0) {
|
if (!node && params.nodes?.length > 0) {
|
||||||
|
const id = params.nodes[0]
|
||||||
|
node = getNodes().find((n) => String(n.id) === String(id)) || null
|
||||||
|
}
|
||||||
|
if (node && String(node.id) !== CONFLICT_CENTER_NODE_ID) {
|
||||||
|
openContextMenu(node, domEvent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let edge = resolveEdgeAtPointer(network, domEvent, getEdges)
|
||||||
|
if (!edge && params.edges?.length > 0) {
|
||||||
const edgeId = params.edges[0]
|
const edgeId = params.edges[0]
|
||||||
edge = getEdges().find((e) => String(e.id) === String(edgeId)) || null
|
edge = getEdges().find((e) => String(e.id) === String(edgeId)) || null
|
||||||
}
|
}
|
||||||
if (!edge) {
|
|
||||||
edge = resolveEdgeAtPointer(network, domEvent, getEdges)
|
|
||||||
}
|
|
||||||
if (edge) {
|
if (edge) {
|
||||||
openEdgeContextMenu(edge, domEvent)
|
openEdgeContextMenu(edge, domEvent)
|
||||||
return
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
openCanvasContextMenu(domEvent)
|
openCanvasContextMenu(domEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
export function formatApiErrorData(data) {
|
export function formatApiErrorData(data) {
|
||||||
if (!data) return ''
|
if (!data) return ''
|
||||||
if (typeof data === 'string' && data.trim()) return data.trim()
|
if (typeof data === 'string' && data.trim()) {
|
||||||
|
const text = data.trim()
|
||||||
|
if (text.startsWith('<') && text.includes('</html>')) {
|
||||||
|
const title = text.match(/<title>([^<]+)<\/title>/i)?.[1]?.trim()
|
||||||
|
if (title) return title
|
||||||
|
return 'Сервер вернул HTML вместо JSON. Проверьте проксирование /api на backend.'
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
if (typeof data?.detail === 'string' && data.detail.trim()) return data.detail.trim()
|
if (typeof data?.detail === 'string' && data.detail.trim()) return data.detail.trim()
|
||||||
if (Array.isArray(data?.non_field_errors) && data.non_field_errors.length) {
|
if (Array.isArray(data?.non_field_errors) && data.non_field_errors.length) {
|
||||||
return data.non_field_errors.join('; ')
|
return data.non_field_errors.join('; ')
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const CSV_HEADERS = [
|
||||||
|
'source',
|
||||||
|
'target',
|
||||||
|
'source_name',
|
||||||
|
'target_name',
|
||||||
|
'relation_type',
|
||||||
|
'description',
|
||||||
|
'interaction_intensity',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function relationToExportRow(relation = {}) {
|
||||||
|
return {
|
||||||
|
source: relation.source ?? '',
|
||||||
|
target: relation.target ?? '',
|
||||||
|
source_name: String(relation.source_name || '').trim(),
|
||||||
|
target_name: String(relation.target_name || '').trim(),
|
||||||
|
relation_type: String(relation.relation_type || 'acquaintance').trim(),
|
||||||
|
description: String(relation.description || '').trim(),
|
||||||
|
interaction_intensity: String(relation.interaction_intensity || 'intense').trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvField(value) {
|
||||||
|
const text = String(value ?? '')
|
||||||
|
if (/[",\n\r]/.test(text)) {
|
||||||
|
return `"${text.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relationsToCsv(relations = []) {
|
||||||
|
const rows = relations.map(relationToExportRow)
|
||||||
|
const lines = [CSV_HEADERS.join(',')]
|
||||||
|
for (const row of rows) {
|
||||||
|
lines.push(CSV_HEADERS.map((key) => escapeCsvField(row[key])).join(','))
|
||||||
|
}
|
||||||
|
return `${lines.join('\n')}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relationsToJson(relations = []) {
|
||||||
|
return JSON.stringify(relations.map(relationToExportRow), null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPORT_FORMATS = {
|
||||||
|
csv: {
|
||||||
|
ext: 'csv',
|
||||||
|
mime: 'text/csv;charset=utf-8',
|
||||||
|
serialize: relationsToCsv,
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
ext: 'json',
|
||||||
|
mime: 'application/json;charset=utf-8',
|
||||||
|
serialize: relationsToJson,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRelationsExport(relations = [], format = 'csv') {
|
||||||
|
const config = EXPORT_FORMATS[format] || EXPORT_FORMATS.csv
|
||||||
|
return {
|
||||||
|
format,
|
||||||
|
filename: `relations-export-${Date.now()}.${config.ext}`,
|
||||||
|
mime: config.mime,
|
||||||
|
content: config.serialize(relations),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,85 @@ function union(parent, a, b) {
|
|||||||
if (ra !== rb) parent.set(ra, rb)
|
if (ra !== rb) parent.set(ra, rb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAdjacency(edges = []) {
|
||||||
|
const adjacency = new Map()
|
||||||
|
const touch = (id) => {
|
||||||
|
const sid = String(id)
|
||||||
|
if (!adjacency.has(sid)) adjacency.set(sid, new Set())
|
||||||
|
return adjacency.get(sid)
|
||||||
|
}
|
||||||
|
for (const edge of edges) {
|
||||||
|
const from = String(edge.from)
|
||||||
|
const to = String(edge.to)
|
||||||
|
touch(from).add(to)
|
||||||
|
touch(to).add(from)
|
||||||
|
}
|
||||||
|
return adjacency
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectComponent(seedId, adjacency) {
|
||||||
|
const start = String(seedId)
|
||||||
|
if (!adjacency.has(start)) return new Set([start])
|
||||||
|
const seen = new Set([start])
|
||||||
|
const queue = [start]
|
||||||
|
while (queue.length) {
|
||||||
|
const current = queue.pop()
|
||||||
|
for (const next of adjacency.get(current) || []) {
|
||||||
|
if (seen.has(next)) continue
|
||||||
|
seen.add(next)
|
||||||
|
queue.push(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextClusterIndex(clusterMap) {
|
||||||
|
let max = -1
|
||||||
|
for (const value of clusterMap.values()) {
|
||||||
|
if (value >= 0) max = Math.max(max, value)
|
||||||
|
}
|
||||||
|
return max + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пересчитывает кластеры только для компонент, затронутых seedNodeIds.
|
||||||
|
* Мутирует clusterMap на месте. Возвращает Set обновлённых nodeId.
|
||||||
|
*/
|
||||||
|
export function updateClusterMapForNodes(clusterMap, nodes, edges, seedNodeIds = []) {
|
||||||
|
const seeds = [...new Set(seedNodeIds.map(String))].filter(Boolean)
|
||||||
|
if (!seeds.length) return new Set()
|
||||||
|
|
||||||
|
const nodeIds = new Set(nodes.map((n) => String(n.id)))
|
||||||
|
const adjacency = buildAdjacency(edges)
|
||||||
|
const affected = new Set()
|
||||||
|
const visited = new Set()
|
||||||
|
|
||||||
|
for (const seed of seeds) {
|
||||||
|
if (!nodeIds.has(seed) || visited.has(seed)) continue
|
||||||
|
const component = collectComponent(seed, adjacency)
|
||||||
|
component.forEach((id) => {
|
||||||
|
visited.add(id)
|
||||||
|
if (nodeIds.has(id)) affected.add(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const size = [...component].filter((id) => nodeIds.has(id)).length
|
||||||
|
let clusterIdx = -1
|
||||||
|
if (size >= 2) {
|
||||||
|
const existing = [...component]
|
||||||
|
.filter((id) => nodeIds.has(id))
|
||||||
|
.map((id) => clusterMap.get(id))
|
||||||
|
.filter((value) => value !== undefined && value >= 0)
|
||||||
|
clusterIdx = existing.length ? Math.min(...existing) : nextClusterIndex(clusterMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of component) {
|
||||||
|
if (nodeIds.has(id)) clusterMap.set(id, clusterIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return affected
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает Map<nodeId, clusterIndex>.
|
* Возвращает Map<nodeId, clusterIndex>.
|
||||||
* Связные компоненты из 2+ узлов получают уникальный индекс цвета,
|
* Связные компоненты из 2+ узлов получают уникальный индекс цвета,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { computeClusterMap } from './clusters'
|
import { computeClusterMap, updateClusterMapForNodes } from './clusters'
|
||||||
|
|
||||||
describe('computeClusterMap', () => {
|
describe('computeClusterMap', () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
@@ -32,3 +32,33 @@ describe('computeClusterMap', () => {
|
|||||||
expect(map.get('a')).not.toBe(map.get('c'))
|
expect(map.get('a')).not.toBe(map.get('c'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('updateClusterMapForNodes', () => {
|
||||||
|
const nodes = [
|
||||||
|
{ id: 'a' },
|
||||||
|
{ id: 'b' },
|
||||||
|
{ id: 'c' },
|
||||||
|
{ id: 'd' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('updates only the merged component when a new edge connects groups', () => {
|
||||||
|
const clusterMap = computeClusterMap(nodes, [
|
||||||
|
{ from: 'a', to: 'b' },
|
||||||
|
{ from: 'c', to: 'd' },
|
||||||
|
])
|
||||||
|
const beforeD = clusterMap.get('d')
|
||||||
|
|
||||||
|
const affected = updateClusterMapForNodes(clusterMap, nodes, [
|
||||||
|
{ from: 'a', to: 'b' },
|
||||||
|
{ from: 'c', to: 'd' },
|
||||||
|
{ from: 'b', to: 'c' },
|
||||||
|
], ['b', 'c'])
|
||||||
|
|
||||||
|
expect(affected.has('a')).toBe(true)
|
||||||
|
expect(affected.has('b')).toBe(true)
|
||||||
|
expect(affected.has('c')).toBe(true)
|
||||||
|
expect(affected.has('d')).toBe(true)
|
||||||
|
expect(clusterMap.get('a')).toBe(clusterMap.get('d'))
|
||||||
|
expect(beforeD).not.toBe(clusterMap.get('d'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
const STORAGE_KEY = 'sg-map-layout-v1'
|
||||||
|
|
||||||
|
const EMPTY = { positions: {}, scale: 1, view: null }
|
||||||
|
|
||||||
|
let memoryByMapId = null
|
||||||
|
|
||||||
|
function readAll() {
|
||||||
|
if (memoryByMapId) return memoryByMapId
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY)
|
||||||
|
memoryByMapId = raw ? JSON.parse(raw) : {}
|
||||||
|
} catch {
|
||||||
|
memoryByMapId = {}
|
||||||
|
}
|
||||||
|
return memoryByMapId
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readMapLayoutCache(mapId) {
|
||||||
|
if (!mapId) return { ...EMPTY }
|
||||||
|
const all = readAll()
|
||||||
|
const entry = all[String(mapId)]
|
||||||
|
return entry ? { ...EMPTY, ...entry } : { ...EMPTY }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeMapLayoutCache(mapId, { positions, scale, view }) {
|
||||||
|
if (!mapId) return
|
||||||
|
const key = String(mapId)
|
||||||
|
const all = readAll()
|
||||||
|
const prev = all[key] || { ...EMPTY }
|
||||||
|
all[key] = {
|
||||||
|
positions: positions ?? prev.positions,
|
||||||
|
scale: scale ?? prev.scale,
|
||||||
|
view: view ?? prev.view,
|
||||||
|
}
|
||||||
|
memoryByMapId = all
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(all))
|
||||||
|
} catch {
|
||||||
|
/* sessionStorage quota */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMapLayoutCache(mapId) {
|
||||||
|
if (!mapId) return
|
||||||
|
const all = readAll()
|
||||||
|
delete all[String(mapId)]
|
||||||
|
memoryByMapId = all
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(all))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const STORAGE_KEYS = {
|
||||||
|
graph: 'ui.graph.topPanelCollapsed',
|
||||||
|
map: 'ui.map.topPanelCollapsed',
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCollapsed(key) {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) === '1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCollapsed(key, collapsed) {
|
||||||
|
try {
|
||||||
|
if (collapsed) {
|
||||||
|
localStorage.setItem(key, '1')
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors (private mode, quota, etc.).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadTopPanelCollapsed(scope) {
|
||||||
|
return readCollapsed(STORAGE_KEYS[scope])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTopPanelCollapsed(scope, collapsed) {
|
||||||
|
writeCollapsed(STORAGE_KEYS[scope], collapsed)
|
||||||
|
}
|
||||||
@@ -19,10 +19,19 @@ import {
|
|||||||
} from '../infrastructure/repositories/repositoryFactory'
|
} from '../infrastructure/repositories/repositoryFactory'
|
||||||
import {
|
import {
|
||||||
importContactsFromFile,
|
importContactsFromFile,
|
||||||
|
importContactsToLocalFromFile,
|
||||||
|
importContactsToRemoteFromFile,
|
||||||
exportContacts as exportContactsUseCase,
|
exportContacts as exportContactsUseCase,
|
||||||
|
exportContactsFromLocal,
|
||||||
|
exportContactsFromRemote,
|
||||||
|
exportRelationsFromLocal,
|
||||||
|
exportRelationsFromRemote,
|
||||||
exportLocalData,
|
exportLocalData,
|
||||||
|
exportRemoteData,
|
||||||
importLocalDump,
|
importLocalDump,
|
||||||
|
importRemoteDump,
|
||||||
} from '../application/usecases/importExport'
|
} from '../application/usecases/importExport'
|
||||||
|
import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
|
||||||
import { syncPendingChanges } from '../application/usecases/sync'
|
import { syncPendingChanges } from '../application/usecases/sync'
|
||||||
|
|
||||||
export const useContactsStore = defineStore('contacts', {
|
export const useContactsStore = defineStore('contacts', {
|
||||||
@@ -162,13 +171,15 @@ export const useContactsStore = defineStore('contacts', {
|
|||||||
this.relations.push(data)
|
this.relations.push(data)
|
||||||
const sid = String(data.source)
|
const sid = String(data.source)
|
||||||
const tid = String(data.target)
|
const tid = String(data.target)
|
||||||
this.contacts = this.contacts.map((c) => {
|
for (let i = 0; i < this.contacts.length; i += 1) {
|
||||||
const id = String(c.id)
|
const id = String(this.contacts[i].id)
|
||||||
if (id === sid || id === tid) {
|
if (id === sid || id === tid) {
|
||||||
return { ...c, relations_count: Number(c.relations_count || 0) + 1 }
|
this.contacts[i] = {
|
||||||
|
...this.contacts[i],
|
||||||
|
relations_count: Number(this.contacts[i].relations_count || 0) + 1,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return c
|
}
|
||||||
})
|
|
||||||
await syncPendingChanges()
|
await syncPendingChanges()
|
||||||
this.bumpDataRevision()
|
this.bumpDataRevision()
|
||||||
return data
|
return data
|
||||||
@@ -209,19 +220,72 @@ export const useContactsStore = defineStore('contacts', {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async importContactsToLocal(file) {
|
||||||
|
return this.withLoading('contactsLoading', async () => {
|
||||||
|
const data = await importContactsToLocalFromFile(file)
|
||||||
|
if (isLocalMode()) {
|
||||||
|
await this.fetchContacts()
|
||||||
|
if (data.isDump) await this.fetchRelations()
|
||||||
|
}
|
||||||
|
if (isLocalMode()) await syncPendingChanges()
|
||||||
|
this.bumpDataRevision()
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async importContactsToRemote(file) {
|
||||||
|
return this.withLoading('contactsLoading', async () => {
|
||||||
|
const data = await importContactsToRemoteFromFile(file)
|
||||||
|
if (isRemoteMode()) {
|
||||||
|
await this.fetchContacts()
|
||||||
|
}
|
||||||
|
this.bumpDataRevision()
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
async exportContacts(format = 'csv') {
|
async exportContacts(format = 'csv') {
|
||||||
return exportContactsUseCase({ format })
|
return exportContactsUseCase({ format })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async exportLocalContacts(format = 'csv') {
|
||||||
|
return exportContactsFromLocal({ format })
|
||||||
|
},
|
||||||
|
|
||||||
|
async exportRemoteContacts(format = 'csv') {
|
||||||
|
return exportContactsFromRemote({ format })
|
||||||
|
},
|
||||||
|
|
||||||
|
async exportLocalRelations(format = 'csv') {
|
||||||
|
return exportRelationsFromLocal({ format })
|
||||||
|
},
|
||||||
|
|
||||||
|
async exportRemoteRelations(format = 'csv') {
|
||||||
|
return exportRelationsFromRemote({ format })
|
||||||
|
},
|
||||||
|
|
||||||
async exportData(passphrase = '') {
|
async exportData(passphrase = '') {
|
||||||
return exportLocalData({ passphrase })
|
return exportLocalData({ passphrase })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async exportRemoteData() {
|
||||||
|
return exportRemoteData()
|
||||||
|
},
|
||||||
|
|
||||||
async importDataDump(file, passphrase = '') {
|
async importDataDump(file, passphrase = '') {
|
||||||
const result = await importLocalDump(file, passphrase)
|
const result = await importLocalDump(file, passphrase)
|
||||||
await Promise.all([this.fetchContacts(), this.fetchRelations()])
|
await Promise.all([this.fetchContacts(), this.fetchRelations()])
|
||||||
this.bumpDataRevision()
|
this.bumpDataRevision()
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async importRemoteDataDump(file, { replace = true, passphrase = '' } = {}) {
|
||||||
|
const result = await importRemoteDump(file, { replace, passphrase })
|
||||||
|
if (isRemoteMode()) {
|
||||||
|
await Promise.all([this.fetchContacts(), this.fetchRelations()])
|
||||||
|
}
|
||||||
|
this.bumpDataRevision()
|
||||||
|
return result
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,19 +2,32 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="page-header__title">
|
<div class="page-header__title">
|
||||||
<button class="btn btn-secondary btn-sm" @click="$router.back()">← Назад</button>
|
<button class="btn btn-secondary btn-sm" type="button" @click="goBack">← Назад</button>
|
||||||
<h2>{{ contact?.name || 'Загрузка...' }}</h2>
|
<h2>{{ contact?.name || 'Загрузка...' }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="contact && !editing" class="page-header__actions">
|
||||||
|
<button class="btn btn-primary btn-sm" type="button" @click="startEdit">Редактировать</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-content" v-if="contact">
|
<div v-if="contact && editing" class="page-content">
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px;">
|
<div class="card contact-edit-page">
|
||||||
<!-- Info card -->
|
<h3 class="contact-edit-page__title">Редактировать контакт</h3>
|
||||||
|
<ContactForm
|
||||||
|
:initial="contact"
|
||||||
|
deletable
|
||||||
|
@submit="onUpdate"
|
||||||
|
@cancel="closeEdit"
|
||||||
|
@delete="confirmDeleteContact"
|
||||||
|
/>
|
||||||
|
<ContactRelationsSection :contact-id="contactId" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="contact" class="page-content">
|
||||||
|
<div class="contact-detail-grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-section-header">
|
<h3 class="card-section-title">Информация</h3>
|
||||||
<h3 class="card-section-title">Информация</h3>
|
|
||||||
<button class="btn btn-primary btn-sm" type="button" @click="editing = true">Редактировать</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<div>{{ contact.email || '—' }}</div>
|
<div>{{ contact.email || '—' }}</div>
|
||||||
@@ -51,102 +64,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Relations card -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex justify-between items-center" style="margin-bottom:16px;">
|
<ContactRelationsSection :contact-id="contactId" standalone />
|
||||||
<h3 style="font-size:14px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;">Связи ({{ contactRelations.length }})</h3>
|
|
||||||
<button class="btn btn-primary btn-sm" @click="showAddRelation = true">+ Добавить</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="contactRelations.length === 0" class="empty-state" style="padding:20px 0;">
|
|
||||||
<p>Нет связей с другими контактами.</p>
|
|
||||||
</div>
|
|
||||||
<div v-else class="relations-list">
|
|
||||||
<div
|
|
||||||
v-for="rel in contactRelations"
|
|
||||||
:key="rel.id"
|
|
||||||
class="relation-row"
|
|
||||||
:class="{ 'is-selected': String(editRelationTarget?.id) === String(rel.id) }"
|
|
||||||
@click="openEditRelation(rel)"
|
|
||||||
>
|
|
||||||
<div class="relation-row__body">
|
|
||||||
<div class="relation-row__main">
|
|
||||||
<span class="relation-row__name">{{ otherContactName(rel) }}</span>
|
|
||||||
<span :class="`badge badge-${rel.relation_type}`">{{ relLabel(rel.relation_type) }}</span>
|
|
||||||
<span class="relation-row__intensity">{{ intensityLabel(rel.interaction_intensity) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="rel.description" class="relation-row__desc">{{ 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>
|
|
||||||
</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">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Редактировать контакт</h3>
|
|
||||||
<button class="btn btn-secondary btn-sm" @click="editing = false">✕</button>
|
|
||||||
</div>
|
|
||||||
<ContactForm
|
|
||||||
:initial="contact"
|
|
||||||
deletable
|
|
||||||
@submit="onUpdate"
|
|
||||||
@cancel="editing = false"
|
|
||||||
@delete="confirmDeleteContact"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add relation modal -->
|
|
||||||
<div v-if="showAddRelation" class="modal-overlay" @click.self="showAddRelation = false">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Добавить связь</h3>
|
|
||||||
<button class="btn btn-secondary btn-sm" @click="showAddRelation = false">✕</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="relError" class="alert alert-error">{{ relError }}</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>С кем связать</label>
|
|
||||||
<SearchableSelect
|
|
||||||
v-model="newRel.targetId"
|
|
||||||
:options="contactSelectOptions"
|
|
||||||
placeholder="Введите имя контакта..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Тип связи</label>
|
|
||||||
<RelationTypeSelect v-model="newRel.type" :options="relationTypes" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Интенсивность общения</label>
|
|
||||||
<InteractionIntensitySelect v-model="newRel.interaction_intensity" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Описание (необязательно)</label>
|
|
||||||
<input v-model="newRel.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
|
|
||||||
</div>
|
|
||||||
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">
|
|
||||||
Стрелка на карте сети идёт от вас к выбранному контакту: вы указаны как источник связи.
|
|
||||||
</p>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" @click="showAddRelation = false">Отмена</button>
|
|
||||||
<button class="btn btn-primary" :disabled="!newRel.targetId" @click="addRelation">Создать связь</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,15 +73,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useContactsStore } from '../stores/contacts'
|
import { useContactsStore } from '../stores/contacts'
|
||||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||||
import ContactForm from '../components/ContactForm.vue'
|
import ContactForm from '../components/ContactForm.vue'
|
||||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
import ContactRelationsSection from '../components/ContactRelationsSection.vue'
|
||||||
import SearchableSelect from '../components/SearchableSelect.vue'
|
|
||||||
import InteractionIntensitySelect from '../components/InteractionIntensitySelect.vue'
|
|
||||||
import RelationTypeSelect from '../components/RelationTypeSelect.vue'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -170,38 +86,9 @@ const store = useContactsStore()
|
|||||||
const mapsStore = useNetworkMapsStore()
|
const mapsStore = useNetworkMapsStore()
|
||||||
const contact = ref(null)
|
const contact = ref(null)
|
||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
const showAddRelation = ref(false)
|
|
||||||
const editRelationOpen = ref(false)
|
|
||||||
const editRelationTarget = ref(null)
|
|
||||||
const relationTypes = ref([])
|
|
||||||
const interactionIntensities = ref([])
|
|
||||||
const relError = ref('')
|
|
||||||
const newRel = ref({
|
|
||||||
targetId: '',
|
|
||||||
type: 'acquaintance',
|
|
||||||
description: '',
|
|
||||||
interaction_intensity: 'intense',
|
|
||||||
})
|
|
||||||
|
|
||||||
const contactId = computed(() => route.params.id)
|
const contactId = computed(() => route.params.id)
|
||||||
|
|
||||||
const contactRelations = computed(() =>
|
|
||||||
store.relations.filter(
|
|
||||||
(r) => String(r.source) === String(contactId.value) || String(r.target) === String(contactId.value)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const otherContacts = computed(() =>
|
|
||||||
store.contacts
|
|
||||||
.filter((c) => String(c.id) !== String(contactId.value))
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
|
||||||
)
|
|
||||||
|
|
||||||
const contactSelectOptions = computed(() =>
|
|
||||||
otherContacts.value.map((c) => ({ value: c.id, label: c.name }))
|
|
||||||
)
|
|
||||||
|
|
||||||
const contactMapNames = computed(() => {
|
const contactMapNames = computed(() => {
|
||||||
const memberships = mapsStore.contactMemberships || []
|
const memberships = mapsStore.contactMemberships || []
|
||||||
return memberships.map((m) => ({
|
return memberships.map((m) => ({
|
||||||
@@ -210,35 +97,34 @@ const contactMapNames = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
function relLabel(type) {
|
function isEditQuery(value) {
|
||||||
return relationTypes.value.find((r) => r.value === type)?.label || type
|
return value === '1' || value === 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
function intensityLabel(v) {
|
function startEdit() {
|
||||||
return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
|
editing.value = true
|
||||||
|
router.replace({ path: `/contacts/${contactId.value}`, query: { edit: '1' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function otherContactName(rel) {
|
function closeEdit() {
|
||||||
const cid = String(contactId.value)
|
editing.value = false
|
||||||
return String(rel.source) === cid ? rel.target_name : rel.source_name
|
if (route.query.edit) {
|
||||||
|
router.replace({ path: `/contacts/${contactId.value}` })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditRelation(rel) {
|
function goBack() {
|
||||||
editRelationTarget.value = rel
|
if (editing.value) {
|
||||||
editRelationOpen.value = true
|
closeEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeEditRelation() {
|
function confirmDeleteContact() {
|
||||||
editRelationOpen.value = false
|
if (!contact.value) return
|
||||||
editRelationTarget.value = null
|
if (!window.confirm(`Удалить контакт «${contact.value.name}» и все его связи?`)) return
|
||||||
}
|
store.deleteContact(contact.value.id).then(() => router.push('/contacts'))
|
||||||
|
|
||||||
function onRelationUpdated() {
|
|
||||||
closeEditRelation()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRelationDeleted() {
|
|
||||||
closeEditRelation()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadContact() {
|
async function loadContact() {
|
||||||
@@ -252,118 +138,44 @@ async function onUpdate(data, mapIds, pluginPayload) {
|
|||||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||||
await saveContactPluginData(contactId.value, pluginPayload)
|
await saveContactPluginData(contactId.value, pluginPayload)
|
||||||
contact.value = { ...contact.value, ...data }
|
contact.value = { ...contact.value, ...data }
|
||||||
editing.value = false
|
closeEdit()
|
||||||
await mapsStore.fetchContactMemberships(contactId.value)
|
await mapsStore.fetchContactMemberships(contactId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRelation() {
|
watch(
|
||||||
relError.value = ''
|
() => route.query.edit,
|
||||||
try {
|
(value) => {
|
||||||
await store.createRelation({
|
editing.value = isEditQuery(value)
|
||||||
source: contactId.value,
|
},
|
||||||
target: newRel.value.targetId,
|
{ immediate: true }
|
||||||
relation_type: newRel.value.type,
|
)
|
||||||
description: newRel.value.description,
|
|
||||||
interaction_intensity: newRel.value.interaction_intensity,
|
|
||||||
})
|
|
||||||
showAddRelation.value = false
|
|
||||||
newRel.value = {
|
|
||||||
targetId: '',
|
|
||||||
type: 'acquaintance',
|
|
||||||
description: '',
|
|
||||||
interaction_intensity: 'intense',
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e.response?.data
|
|
||||||
relError.value = typeof msg === 'object' ? JSON.stringify(msg) : String(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeRelation(id) {
|
watch(
|
||||||
await store.deleteRelation(id)
|
() => route.params.id,
|
||||||
}
|
async (id) => {
|
||||||
|
if (!id) return
|
||||||
|
await loadContact()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await mapsStore.fetchMaps()
|
await mapsStore.fetchMaps()
|
||||||
await loadContact()
|
await loadContact()
|
||||||
await Promise.all([store.fetchContacts(), store.fetchRelations()])
|
await Promise.all([store.fetchContacts(), store.fetchRelations()])
|
||||||
const [rt, intensities] = await Promise.all([
|
|
||||||
store.fetchRelationTypes(),
|
|
||||||
store.fetchNetworkMapChoices(),
|
|
||||||
])
|
|
||||||
relationTypes.value = rt
|
|
||||||
interactionIntensities.value = intensities?.interaction_intensities || []
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.relations-list {
|
.contact-detail-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
.contact-edit-page__title {
|
||||||
.relation-row {
|
margin: 0 0 20px;
|
||||||
display: flex;
|
font-size: 16px;
|
||||||
align-items: center;
|
font-weight: 600;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.relation-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-row:hover {
|
|
||||||
background: var(--surface-alt);
|
|
||||||
margin: 0 -12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-row.is-selected {
|
|
||||||
background: var(--accent-dim);
|
|
||||||
margin: 0 -12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-row__body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-row__main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-row__name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-row__intensity {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-row__desc {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-row__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-link {
|
.map-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
|||||||
@@ -41,14 +41,6 @@
|
|||||||
Ctrl+клик (⌘+клик на Mac) по двум контактам — создать связь.
|
Ctrl+клик (⌘+клик на Mac) по двум контактам — создать связь.
|
||||||
</p>
|
</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-if="store.loading" class="spinner"></div>
|
||||||
<div v-else-if="store.contacts.length === 0" class="empty-state">
|
<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">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -63,9 +55,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="col-check">
|
<th class="col-check">
|
||||||
<input
|
<input
|
||||||
|
ref="selectAllCheckbox"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="allSelected"
|
:checked="allSelected"
|
||||||
:indeterminate="someSelected && !allSelected"
|
|
||||||
aria-label="Выбрать все"
|
aria-label="Выбрать все"
|
||||||
@click.stop.prevent="toggleSelectAll"
|
@click.stop.prevent="toggleSelectAll"
|
||||||
/>
|
/>
|
||||||
@@ -87,16 +79,23 @@
|
|||||||
}"
|
}"
|
||||||
@click="onRowClick(c, $event)"
|
@click="onRowClick(c, $event)"
|
||||||
>
|
>
|
||||||
<td class="col-check" @click.stop>
|
<td class="col-check" @click.stop="toggleSelect(c.id)">
|
||||||
<input
|
<input
|
||||||
|
v-model="selectedIds"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="isSelected(c.id)"
|
:value="String(c.id)"
|
||||||
:aria-label="`Выбрать ${c.name}`"
|
:aria-label="`Выбрать ${c.name}`"
|
||||||
@click.stop.prevent="toggleSelect(c.id)"
|
@click.stop
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="font-weight:500;">{{ c.name }}</div>
|
<router-link
|
||||||
|
:to="`/contacts/${c.id}`"
|
||||||
|
class="contact-name"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
{{ c.name }}
|
||||||
|
</router-link>
|
||||||
<div class="text-muted mt-1">{{ c.position }}</div>
|
<div class="text-muted mt-1">{{ c.position }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ c.organization || '—' }}</td>
|
<td>{{ c.organization || '—' }}</td>
|
||||||
@@ -124,22 +123,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="editTarget" class="modal-overlay" @click.self="editTarget = null">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Редактировать контакт</h3>
|
|
||||||
<button class="btn btn-secondary btn-sm" @click="editTarget = null">✕</button>
|
|
||||||
</div>
|
|
||||||
<ContactForm
|
|
||||||
:initial="editTarget"
|
|
||||||
deletable
|
|
||||||
@submit="onUpdate"
|
|
||||||
@cancel="editTarget = null"
|
|
||||||
@delete="onDeleteFromEdit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
|
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -183,7 +166,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useContactsStore } from '../stores/contacts'
|
import { useContactsStore } from '../stores/contacts'
|
||||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||||
@@ -191,17 +174,17 @@ import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
|||||||
import ContactForm from '../components/ContactForm.vue'
|
import ContactForm from '../components/ContactForm.vue'
|
||||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const store = useContactsStore()
|
const store = useContactsStore()
|
||||||
const mapsStore = useNetworkMapsStore()
|
const mapsStore = useNetworkMapsStore()
|
||||||
const router = useRouter()
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const editTarget = ref(null)
|
|
||||||
const deleteTarget = ref(null)
|
const deleteTarget = ref(null)
|
||||||
const bulkDeleteOpen = ref(false)
|
const bulkDeleteOpen = ref(false)
|
||||||
const bulkDeleting = ref(false)
|
const bulkDeleting = ref(false)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
const selectedIds = ref([])
|
const selectedIds = ref([])
|
||||||
|
const selectAllCheckbox = ref(null)
|
||||||
const relationModalOpen = ref(false)
|
const relationModalOpen = ref(false)
|
||||||
const relationPair = ref(null)
|
const relationPair = ref(null)
|
||||||
|
|
||||||
@@ -226,6 +209,12 @@ const allSelected = computed(() =>
|
|||||||
|
|
||||||
const someSelected = computed(() => selectedCount.value > 0)
|
const someSelected = computed(() => selectedCount.value > 0)
|
||||||
|
|
||||||
|
watch([allSelected, someSelected], () => {
|
||||||
|
if (selectAllCheckbox.value) {
|
||||||
|
selectAllCheckbox.value.indeterminate = someSelected.value && !allSelected.value
|
||||||
|
}
|
||||||
|
}, { flush: 'post' })
|
||||||
|
|
||||||
function isSelected(id) {
|
function isSelected(id) {
|
||||||
const sid = String(id)
|
const sid = String(id)
|
||||||
return selectedIds.value.includes(sid)
|
return selectedIds.value.includes(sid)
|
||||||
@@ -264,7 +253,7 @@ function onSearch() {
|
|||||||
|
|
||||||
function onRowClick(c, event) {
|
function onRowClick(c, event) {
|
||||||
if (handleCtrlPick(c, event)) return
|
if (handleCtrlPick(c, event)) return
|
||||||
goTo(c.id)
|
toggleSelect(c.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeRelationModal() {
|
function closeRelationModal() {
|
||||||
@@ -279,8 +268,6 @@ function onRelationCreated() {
|
|||||||
clearLinkSelection()
|
clearLinkSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
function goTo(id) { router.push(`/contacts/${id}`) }
|
|
||||||
|
|
||||||
async function onCreate(data, mapIds, pluginPayload) {
|
async function onCreate(data, mapIds, pluginPayload) {
|
||||||
const created = await store.createContact(data)
|
const created = await store.createContact(data)
|
||||||
if (mapIds?.length) {
|
if (mapIds?.length) {
|
||||||
@@ -292,15 +279,7 @@ async function onCreate(data, mapIds, pluginPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(c) {
|
function openEdit(c) {
|
||||||
editTarget.value = { ...c }
|
router.push({ name: 'ContactDetail', params: { id: c.id }, query: { edit: '1' } })
|
||||||
}
|
|
||||||
|
|
||||||
async function onUpdate(data, mapIds, pluginPayload) {
|
|
||||||
await store.updateContact(editTarget.value.id, data)
|
|
||||||
await mapsStore.setContactMapMemberships(editTarget.value.id, mapIds)
|
|
||||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
|
||||||
await saveContactPluginData(editTarget.value.id, pluginPayload)
|
|
||||||
editTarget.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(c) { deleteTarget.value = c }
|
function confirmDelete(c) { deleteTarget.value = c }
|
||||||
@@ -354,16 +333,14 @@ tr.is-link-selected {
|
|||||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||||
box-shadow: inset 3px 0 0 var(--green);
|
box-shadow: inset 3px 0 0 var(--green);
|
||||||
}
|
}
|
||||||
.link-hint {
|
.contact-name {
|
||||||
font-size: 12px;
|
display: inline-block;
|
||||||
margin-bottom: 12px;
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.link-hint--static {
|
.contact-name:hover {
|
||||||
margin: 0 0 12px;
|
color: var(--accent);
|
||||||
}
|
|
||||||
tr.is-link-selected {
|
|
||||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
|
||||||
box-shadow: inset 3px 0 0 var(--green);
|
|
||||||
}
|
}
|
||||||
.link-hint {
|
.link-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
+379
-150
@@ -3,36 +3,29 @@
|
|||||||
<GraphHeaderPanel
|
<GraphHeaderPanel
|
||||||
v-show="!chromeCollapsed"
|
v-show="!chromeCollapsed"
|
||||||
title="Граф связей"
|
title="Граф связей"
|
||||||
|
:show-reset="false"
|
||||||
:show-physics-toggle="true"
|
:show-physics-toggle="true"
|
||||||
:physics-enabled="physicsEnabled"
|
:physics-enabled="physicsEnabled"
|
||||||
@reset="resetView"
|
|
||||||
@toggle-physics="togglePhysics"
|
@toggle-physics="togglePhysics"
|
||||||
/>
|
>
|
||||||
|
<template #actions>
|
||||||
<div v-show="!chromeCollapsed" class="graph-view-toolbar">
|
<button type="button" class="btn btn-secondary btn-sm" @click="filtersOpen = true">
|
||||||
<p class="graph-link-hint text-muted">
|
Фильтры
|
||||||
Ctrl+клик (⌘+клик) по двум узлам — создать связь.
|
|
||||||
<span v-if="linkSelectionCount === 1">
|
|
||||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<RelationTypeFilters
|
|
||||||
:relation-types="allRelationTypes"
|
|
||||||
:active-values="activeFilters"
|
|
||||||
@toggle="toggleFilter"
|
|
||||||
/>
|
|
||||||
<div v-if="graphToolbarActions.length" class="graph-plugin-actions">
|
|
||||||
<button
|
|
||||||
v-for="action in graphToolbarActions"
|
|
||||||
:key="action.id"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary btn-sm"
|
|
||||||
@click="runGraphToolbarAction(action)"
|
|
||||||
>
|
|
||||||
{{ action.label }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</GraphHeaderPanel>
|
||||||
|
|
||||||
|
<GraphFiltersModal
|
||||||
|
:open="filtersOpen"
|
||||||
|
:relation-types="allRelationTypes"
|
||||||
|
:active-filters="activeFilters"
|
||||||
|
:link-selection-count="linkSelectionCount"
|
||||||
|
:link-selection="linkSelection"
|
||||||
|
:toolbar-actions="graphToolbarActions"
|
||||||
|
@close="filtersOpen = false"
|
||||||
|
@toggle="toggleFilter"
|
||||||
|
@toolbar-action="runGraphToolbarAction"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
|
<div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
|
||||||
<div class="graph-chrome-bar">
|
<div class="graph-chrome-bar">
|
||||||
@@ -56,26 +49,6 @@
|
|||||||
<span>{{ chromeCollapsed ? 'Показать панели' : 'Свернуть панели' }}</span>
|
<span>{{ chromeCollapsed ? 'Показать панели' : 'Свернуть панели' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
v-if="!loading && nodes.length > 0"
|
|
||||||
type="button"
|
|
||||||
class="graph-fullscreen-btn"
|
|
||||||
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
|
|
||||||
@click="toggleFullscreen"
|
|
||||||
>
|
|
||||||
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
|
|
||||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
|
|
||||||
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
|
|
||||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
|
|
||||||
</svg>
|
|
||||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M4 14h6v6"/>
|
|
||||||
<path d="M20 10h-6V4"/>
|
|
||||||
<path d="M14 10l7-7"/>
|
|
||||||
<path d="M3 21l7-7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div v-if="loading" class="spinner"></div>
|
<div v-if="loading" class="spinner"></div>
|
||||||
<div v-else-if="nodes.length === 0" class="empty-state card" @contextmenu.prevent="onGraphAreaContextMenu">
|
<div v-else-if="nodes.length === 0" class="empty-state card" @contextmenu.prevent="onGraphAreaContextMenu">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -83,7 +56,41 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<p>Нет контактов. <RouterLink to="/contacts">Добавьте контакты</RouterLink> или <RouterLink to="/import">импортируйте файл</RouterLink>.</p>
|
<p>Нет контактов. <RouterLink to="/contacts">Добавьте контакты</RouterLink> или <RouterLink to="/import">импортируйте файл</RouterLink>.</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else id="graph-container" ref="graphContainer"></div>
|
<div v-else class="graph-stack" ref="graphStack">
|
||||||
|
<div class="graph-view-tools">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="graph-fit-btn btn btn-secondary btn-sm"
|
||||||
|
title="По центру"
|
||||||
|
@click="fitView"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||||
|
</svg>
|
||||||
|
По центру
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="graph-fullscreen-btn"
|
||||||
|
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
|
||||||
|
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
|
||||||
|
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||||
|
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 14h6v6"/>
|
||||||
|
<path d="M20 10h-6V4"/>
|
||||||
|
<path d="M14 10l7-7"/>
|
||||||
|
<path d="M3 21l7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="graph-container" ref="graphContainer"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Node detail panel -->
|
<!-- Node detail panel -->
|
||||||
@@ -127,7 +134,6 @@
|
|||||||
:x="contextMenuX"
|
:x="contextMenuX"
|
||||||
:y="contextMenuY"
|
:y="contextMenuY"
|
||||||
@close="closeContextMenu"
|
@close="closeContextMenu"
|
||||||
@info="openNodeInfo"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GraphEdgeContextMenu
|
<GraphEdgeContextMenu
|
||||||
@@ -149,6 +155,9 @@
|
|||||||
|
|
||||||
<CreateContactModal
|
<CreateContactModal
|
||||||
:open="createContactOpen"
|
:open="createContactOpen"
|
||||||
|
:show-relation-link="graphContactOptions.length > 0"
|
||||||
|
:link-to-options="graphContactOptions"
|
||||||
|
:initial-link-to-id="linkSelection[0]?.id"
|
||||||
@close="createContactOpen = false"
|
@close="createContactOpen = false"
|
||||||
@created="onContactCreated"
|
@created="onContactCreated"
|
||||||
/>
|
/>
|
||||||
@@ -175,19 +184,19 @@
|
|||||||
defineOptions({ name: 'Graph' })
|
defineOptions({ name: 'Graph' })
|
||||||
|
|
||||||
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
import { Network, DataSet } from 'vis-network/standalone'
|
import { Network, DataSet } from 'vis-network/standalone'
|
||||||
import { useContactsStore } from '../stores/contacts'
|
import { useContactsStore } from '../stores/contacts'
|
||||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||||
import { clusterColor } from '../lib/graph/clusterColors'
|
import { clusterColor } from '../lib/graph/clusterColors'
|
||||||
import { computeClusterMap } from '../lib/graph/clusters'
|
import { computeClusterMap, updateClusterMapForNodes } from '../lib/graph/clusters'
|
||||||
import { computeGraphSeedPositions } from '../lib/graph/graphLayout'
|
import { computeGraphSeedPositions } from '../lib/graph/graphLayout'
|
||||||
import { readGraphLayoutCache, writeGraphLayoutCache } from '../lib/graph/graphLayoutCache'
|
import { readGraphLayoutCache, writeGraphLayoutCache } from '../lib/graph/graphLayoutCache'
|
||||||
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
|
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
|
||||||
import { buildGraphFromStore, edgeFromRelation } from '../application/usecases/graph'
|
import { buildGraphFromStore, edgeFromRelation } from '../application/usecases/graph'
|
||||||
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
|
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
|
||||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
import GraphFiltersModal from '../components/GraphFiltersModal.vue'
|
||||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||||
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
||||||
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
||||||
@@ -197,6 +206,7 @@ import EditRelationModal from '../components/EditRelationModal.vue'
|
|||||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||||
import { getGraphToolbarActions } from '../core/pluginRegistry'
|
import { getGraphToolbarActions } from '../core/pluginRegistry'
|
||||||
|
import { loadTopPanelCollapsed, saveTopPanelCollapsed } from '../lib/ui/topPanelCollapseStorage'
|
||||||
|
|
||||||
let themeObserver = null
|
let themeObserver = null
|
||||||
let detachContextHandler = null
|
let detachContextHandler = null
|
||||||
@@ -221,10 +231,6 @@ const {
|
|||||||
|
|
||||||
const createContactOpen = ref(false)
|
const createContactOpen = ref(false)
|
||||||
|
|
||||||
function openNodeInfo(node) {
|
|
||||||
selectedNode.value = node || null
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateContact() {
|
function openCreateContact() {
|
||||||
closeContextMenu()
|
closeContextMenu()
|
||||||
createContactOpen.value = true
|
createContactOpen.value = true
|
||||||
@@ -235,15 +241,29 @@ function onGraphAreaContextMenu(event) {
|
|||||||
openCanvasContextMenu(event)
|
openCanvasContextMenu(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onContactCreated(data, mapIds, pluginPayload) {
|
async function onContactCreated(data, mapIds, pluginPayload, relationLink) {
|
||||||
const created = await store.createContact(data)
|
const created = await store.createContact(data)
|
||||||
if (mapIds?.length) {
|
if (mapIds?.length) {
|
||||||
await mapsStore.setContactMapMemberships(created.id, mapIds)
|
await mapsStore.setContactMapMemberships(created.id, mapIds)
|
||||||
}
|
}
|
||||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||||
await saveContactPluginData(created.id, pluginPayload)
|
await saveContactPluginData(created.id, pluginPayload)
|
||||||
|
|
||||||
|
let relation = null
|
||||||
|
if (relationLink?.targetId && String(relationLink.targetId) !== String(created.id)) {
|
||||||
|
relation = await store.createRelation({
|
||||||
|
source: created.id,
|
||||||
|
target: relationLink.targetId,
|
||||||
|
relation_type: relationLink.type,
|
||||||
|
description: relationLink.description || '',
|
||||||
|
interaction_intensity: relationLink.intensity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
createContactOpen.value = false
|
createContactOpen.value = false
|
||||||
|
clearLinkSelection()
|
||||||
await ensureGraphReady({ showSpinner: false })
|
await ensureGraphReady({ showSpinner: false })
|
||||||
|
if (relation) appendRelationEdge(relation)
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = useContactsStore()
|
const store = useContactsStore()
|
||||||
@@ -251,10 +271,12 @@ const mapsStore = useNetworkMapsStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const graphToolbarActions = getGraphToolbarActions()
|
const graphToolbarActions = getGraphToolbarActions()
|
||||||
const graphArea = ref(null)
|
const graphArea = ref(null)
|
||||||
|
const graphStack = ref(null)
|
||||||
const graphContainer = ref(null)
|
const graphContainer = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(false)
|
||||||
const chromeCollapsed = ref(false)
|
const filtersOpen = ref(false)
|
||||||
|
const chromeCollapsed = ref(loadTopPanelCollapsed('graph'))
|
||||||
const network = ref(null)
|
const network = ref(null)
|
||||||
const physicsEnabled = ref(true)
|
const physicsEnabled = ref(true)
|
||||||
const selectedNode = ref(null)
|
const selectedNode = ref(null)
|
||||||
@@ -282,6 +304,8 @@ const INIT_RETRY_MAX = 40
|
|||||||
let initRetryTimer = null
|
let initRetryTimer = null
|
||||||
let resizeObserver = null
|
let resizeObserver = null
|
||||||
let syncedRevision = -1
|
let syncedRevision = -1
|
||||||
|
let graphViewActive = false
|
||||||
|
let layoutSnapshotOnLeave = null
|
||||||
let initialLayoutDone = false
|
let initialLayoutDone = false
|
||||||
|
|
||||||
const nodes = ref([])
|
const nodes = ref([])
|
||||||
@@ -289,11 +313,19 @@ const edges = ref([])
|
|||||||
const allRelationTypes = ref([])
|
const allRelationTypes = ref([])
|
||||||
const activeFilters = ref([])
|
const activeFilters = ref([])
|
||||||
const clusterMap = ref(new Map())
|
const clusterMap = ref(new Map())
|
||||||
|
let prevLinkSelectionIds = new Set()
|
||||||
|
|
||||||
const selectedContact = computed(() =>
|
const selectedContact = computed(() =>
|
||||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const graphContactOptions = computed(() =>
|
||||||
|
store.contacts.map((c) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: [c.name, c.organization].filter(Boolean).join(' · '),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
function cssVar(name, fallback) {
|
function cssVar(name, fallback) {
|
||||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
return value || fallback
|
return value || fallback
|
||||||
@@ -315,6 +347,89 @@ function recomputeClusters() {
|
|||||||
clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
|
clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateClustersLocal(seedNodeIds) {
|
||||||
|
const map = clusterMap.value
|
||||||
|
const affected = updateClusterMapForNodes(
|
||||||
|
map,
|
||||||
|
nodes.value,
|
||||||
|
filteredEdges(),
|
||||||
|
seedNodeIds
|
||||||
|
)
|
||||||
|
clusterMap.value = map
|
||||||
|
return affected
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshNodeStylesForIds(nodeIds) {
|
||||||
|
if (!nodesDS || !nodeIds?.size) return
|
||||||
|
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||||
|
const livePositions = network.value?.getPositions() || {}
|
||||||
|
const idSet = new Set([...nodeIds].map(String))
|
||||||
|
const targets = nodes.value.filter((n) => idSet.has(String(n.id)))
|
||||||
|
if (!targets.length) return
|
||||||
|
nodesDS.update(
|
||||||
|
targets.map((n) => {
|
||||||
|
const id = String(n.id)
|
||||||
|
const vis = mapGraphNodeToVis(n, linkIds)
|
||||||
|
const pos = livePositions[id]
|
||||||
|
return pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
||||||
|
})
|
||||||
|
)
|
||||||
|
network.value?.redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshNodeStyles() {
|
||||||
|
refreshNodeStylesForIds(new Set(nodes.value.map((n) => String(n.id))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPhysicsPaused(fn) {
|
||||||
|
if (!network.value) {
|
||||||
|
fn()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedPositions = network.value.getPositions()
|
||||||
|
const savedView = {
|
||||||
|
position: network.value.getViewPosition(),
|
||||||
|
scale: network.value.getScale(),
|
||||||
|
}
|
||||||
|
const wasEnabled = physicsEnabled.value
|
||||||
|
|
||||||
|
if (wasEnabled) {
|
||||||
|
network.value.setOptions({ physics: physicsOptions(false) })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fn()
|
||||||
|
} finally {
|
||||||
|
if (nodesDS && savedPositions) {
|
||||||
|
nodesDS.update(
|
||||||
|
Object.entries(savedPositions).map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (wasEnabled) {
|
||||||
|
network.value.setOptions({ physics: physicsOptions(true) })
|
||||||
|
}
|
||||||
|
if (savedView.position) {
|
||||||
|
network.value.moveTo({
|
||||||
|
position: savedView.position,
|
||||||
|
scale: savedView.scale || 1,
|
||||||
|
animation: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
saveLayoutSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLocalEdgeChange(seedNodeIds, mutate) {
|
||||||
|
withPhysicsPaused(() => {
|
||||||
|
mutate()
|
||||||
|
const affected = updateClustersLocal(seedNodeIds)
|
||||||
|
const styleIds = new Set([...seedNodeIds].map(String))
|
||||||
|
affected.forEach((id) => styleIds.add(id))
|
||||||
|
refreshNodeStylesForIds(styleIds)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function nodeDegree(id) {
|
function nodeDegree(id) {
|
||||||
const sid = String(id)
|
const sid = String(id)
|
||||||
return filteredEdges().filter(
|
return filteredEdges().filter(
|
||||||
@@ -363,15 +478,11 @@ function mapGraphNodeToVis(n, linkIds = new Set()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshNodeStyles() {
|
|
||||||
if (!nodesDS) return
|
|
||||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
|
||||||
nodesDS.update(nodes.value.map((n) => mapGraphNodeToVis(n, linkIds)))
|
|
||||||
network.value?.redraw()
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyLinkHighlights() {
|
function applyLinkHighlights() {
|
||||||
refreshNodeStyles()
|
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||||
|
const affected = new Set([...linkIds, ...prevLinkSelectionIds])
|
||||||
|
prevLinkSelectionIds = linkIds
|
||||||
|
refreshNodeStylesForIds(affected)
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapGraphEdgeToVis(e) {
|
function mapGraphEdgeToVis(e) {
|
||||||
@@ -443,48 +554,53 @@ function updateGraphEdge(relation) {
|
|||||||
|
|
||||||
function onRelationUpdated(relation) {
|
function onRelationUpdated(relation) {
|
||||||
closeEditRelation()
|
closeEditRelation()
|
||||||
updateGraphEdge(relation)
|
|
||||||
syncedRevision = store.dataRevision
|
syncedRevision = store.dataRevision
|
||||||
if (network.value && physicsEnabled.value) {
|
const edge = edgeFromRelation(relation)
|
||||||
network.value.stabilize(80)
|
const seedNodeIds = [edge.from, edge.to]
|
||||||
}
|
applyLocalEdgeChange(seedNodeIds, () => {
|
||||||
|
updateGraphEdge(relation)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeGraphEdge(relationId) {
|
function removeGraphEdge(relationId) {
|
||||||
const sid = String(relationId)
|
const sid = String(relationId)
|
||||||
edges.value = edges.value.filter((e) => String(e.id) !== sid)
|
const removed = edges.value.find((e) => String(e.id) === sid)
|
||||||
if (edgesDS?.get(sid)) edgesDS.remove(sid)
|
const seedNodeIds = removed ? [removed.from, removed.to] : []
|
||||||
recomputeClusters()
|
applyLocalEdgeChange(seedNodeIds, () => {
|
||||||
refreshNodeStyles()
|
edges.value = edges.value.filter((e) => String(e.id) !== sid)
|
||||||
|
if (edgesDS?.get(sid)) edgesDS.remove(sid)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRelationDeleted(relationId) {
|
function onRelationDeleted(relationId) {
|
||||||
closeEditRelation()
|
closeEditRelation()
|
||||||
removeGraphEdge(relationId)
|
|
||||||
syncedRevision = store.dataRevision
|
syncedRevision = store.dataRevision
|
||||||
|
removeGraphEdge(relationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendRelationEdge(relation) {
|
function appendRelationEdge(relation) {
|
||||||
if (!relation) return
|
if (!relation) return
|
||||||
const edge = edgeFromRelation(relation)
|
const edge = edgeFromRelation(relation)
|
||||||
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
|
const seedNodeIds = [edge.from, edge.to]
|
||||||
edges.value.push(edge)
|
|
||||||
}
|
|
||||||
if (!edgesDS) return
|
|
||||||
|
|
||||||
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
applyLocalEdgeChange(seedNodeIds, () => {
|
||||||
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
|
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
|
||||||
if (
|
edges.value.push(edge)
|
||||||
activeFilters.value.length < allRelationTypes.value.length &&
|
}
|
||||||
!activeFilters.value.includes(edge.relation_type)
|
if (!edgesDS) return
|
||||||
) {
|
|
||||||
return
|
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||||
}
|
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
|
||||||
if (!edgesDS.get(String(edge.id))) {
|
if (
|
||||||
edgesDS.add(mapGraphEdgeToVis(edge))
|
activeFilters.value.length < allRelationTypes.value.length &&
|
||||||
}
|
!activeFilters.value.includes(edge.relation_type)
|
||||||
recomputeClusters()
|
) {
|
||||||
refreshNodeStyles()
|
return
|
||||||
|
}
|
||||||
|
if (!edgesDS.get(String(edge.id))) {
|
||||||
|
edgesDS.add(mapGraphEdgeToVis(edge))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeRelationModal() {
|
function closeRelationModal() {
|
||||||
@@ -495,12 +611,12 @@ function closeRelationModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onRelationCreated(relation) {
|
function onRelationCreated(relation) {
|
||||||
|
syncedRevision = store.dataRevision
|
||||||
relationModalOpen.value = false
|
relationModalOpen.value = false
|
||||||
relationPair.value = null
|
relationPair.value = null
|
||||||
clearLinkSelection()
|
clearLinkSelection()
|
||||||
applyLinkHighlights()
|
applyLinkHighlights()
|
||||||
appendRelationEdge(relation)
|
appendRelationEdge(relation)
|
||||||
syncedRevision = store.dataRevision
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(linkSelection, () => {
|
watch(linkSelection, () => {
|
||||||
@@ -552,6 +668,40 @@ function saveLayoutSnapshot() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function captureLayoutSnapshot() {
|
||||||
|
if (!network.value) return null
|
||||||
|
const view = network.value.getViewPosition()
|
||||||
|
return {
|
||||||
|
positions: { ...network.value.getPositions() },
|
||||||
|
scale: network.value.getScale(),
|
||||||
|
view: view ? { x: view.x, y: view.y } : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLayoutSnapshot(snapshot) {
|
||||||
|
if (!network.value || !nodesDS || !snapshot) return
|
||||||
|
const updates = Object.entries(snapshot.positions || {})
|
||||||
|
.filter(([id]) => nodesDS.get(id))
|
||||||
|
.map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
|
||||||
|
if (updates.length) nodesDS.update(updates)
|
||||||
|
if (snapshot.view) {
|
||||||
|
network.value.moveTo({
|
||||||
|
position: snapshot.view,
|
||||||
|
scale: snapshot.scale || 1,
|
||||||
|
animation: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeGraphLayoutCache(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeNetworkCanvas() {
|
||||||
|
if (!network.value || !graphContainer.value) return
|
||||||
|
const { offsetWidth: w, offsetHeight: h } = graphContainer.value
|
||||||
|
if (w > 10 && h > 10) {
|
||||||
|
network.value.setSize(`${w}px`, `${h}px`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function restoreViewport() {
|
function restoreViewport() {
|
||||||
if (!network.value) return
|
if (!network.value) return
|
||||||
const cache = readGraphLayoutCache()
|
const cache = readGraphLayoutCache()
|
||||||
@@ -588,12 +738,45 @@ function teardownNetwork() {
|
|||||||
edgesDS = null
|
edgesDS = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncGraphMetadataOnly(lockedPositions) {
|
||||||
|
if (!network.value || !nodesDS || !edgesDS) return
|
||||||
|
|
||||||
|
const positions = lockedPositions || network.value.getPositions()
|
||||||
|
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||||
|
|
||||||
|
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 = positions[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()
|
||||||
|
}
|
||||||
|
|
||||||
function syncGraphToNetwork() {
|
function syncGraphToNetwork() {
|
||||||
if (!network.value || !nodesDS || !edgesDS) return
|
if (!network.value || !nodesDS || !edgesDS) return
|
||||||
|
|
||||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||||
const livePositions = network.value.getPositions()
|
|
||||||
const cachedPositions = readGraphLayoutCache().positions || {}
|
const cachedPositions = readGraphLayoutCache().positions || {}
|
||||||
|
const livePositions = network.value.getPositions()
|
||||||
const seeds = computeGraphSeedPositions(nodes.value, filteredEdges())
|
const seeds = computeGraphSeedPositions(nodes.value, filteredEdges())
|
||||||
|
|
||||||
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||||
@@ -604,7 +787,7 @@ function syncGraphToNetwork() {
|
|||||||
nodes.value.forEach((n) => {
|
nodes.value.forEach((n) => {
|
||||||
const id = String(n.id)
|
const id = String(n.id)
|
||||||
const vis = mapGraphNodeToVis(n, linkIds)
|
const vis = mapGraphNodeToVis(n, linkIds)
|
||||||
const pos = livePositions[id] || cachedPositions[id] || seeds.get(id)
|
const pos = cachedPositions[id] || livePositions[id] || seeds.get(id)
|
||||||
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
||||||
if (nodesDS.get(id)) nodesDS.update(payload)
|
if (nodesDS.get(id)) nodesDS.update(payload)
|
||||||
else nodesDS.add(payload)
|
else nodesDS.add(payload)
|
||||||
@@ -623,6 +806,7 @@ function syncGraphToNetwork() {
|
|||||||
recomputeClusters()
|
recomputeClusters()
|
||||||
refreshNodeStyles()
|
refreshNodeStyles()
|
||||||
syncedRevision = store.dataRevision
|
syncedRevision = store.dataRevision
|
||||||
|
restoreViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
let ensureGraphReadyInFlight = null
|
let ensureGraphReadyInFlight = null
|
||||||
@@ -820,8 +1004,8 @@ function applyThemeToNetwork() {
|
|||||||
network.value.redraw()
|
network.value.redraw()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetView() {
|
function fitView() {
|
||||||
network.value?.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } })
|
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePhysics() {
|
function togglePhysics() {
|
||||||
@@ -830,7 +1014,7 @@ function togglePhysics() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFullscreen() {
|
async function toggleFullscreen() {
|
||||||
const el = graphArea.value
|
const el = graphStack.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
try {
|
try {
|
||||||
if (document.fullscreenElement === el) {
|
if (document.fullscreenElement === el) {
|
||||||
@@ -844,7 +1028,7 @@ async function toggleFullscreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onFullscreenChange() {
|
function onFullscreenChange() {
|
||||||
isFullscreen.value = document.fullscreenElement === graphArea.value
|
isFullscreen.value = document.fullscreenElement === graphStack.value
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
network.value?.redraw()
|
network.value?.redraw()
|
||||||
network.value?.fit({ animation: false })
|
network.value?.fit({ animation: false })
|
||||||
@@ -853,6 +1037,7 @@ function onFullscreenChange() {
|
|||||||
|
|
||||||
function toggleChrome() {
|
function toggleChrome() {
|
||||||
chromeCollapsed.value = !chromeCollapsed.value
|
chromeCollapsed.value = !chromeCollapsed.value
|
||||||
|
saveTopPanelCollapsed('graph', chromeCollapsed.value)
|
||||||
nextTick(() => network.value?.redraw())
|
nextTick(() => network.value?.redraw())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,7 +1046,9 @@ function runGraphToolbarAction(action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(() => store.dataRevision, async (revision) => {
|
watch(() => store.dataRevision, async (revision) => {
|
||||||
if (!network.value || revision === syncedRevision) return
|
if (!network.value || !graphViewActive) return
|
||||||
|
await nextTick()
|
||||||
|
if (revision === syncedRevision) return
|
||||||
await applyGraphDataFromStore()
|
await applyGraphDataFromStore()
|
||||||
if (nodes.value.length === 0) {
|
if (nodes.value.length === 0) {
|
||||||
teardownNetwork()
|
teardownNetwork()
|
||||||
@@ -880,20 +1067,58 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeRouteLeave(() => {
|
||||||
|
if (network.value && physicsEnabled.value) {
|
||||||
|
network.value.setOptions({ physics: physicsOptions(false) })
|
||||||
|
}
|
||||||
|
layoutSnapshotOnLeave = captureLayoutSnapshot()
|
||||||
|
saveLayoutSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
|
graphViewActive = true
|
||||||
|
const snapshot = layoutSnapshotOnLeave
|
||||||
|
layoutSnapshotOnLeave = null
|
||||||
|
|
||||||
if (network.value) {
|
if (network.value) {
|
||||||
network.value.redraw()
|
network.value.setOptions({ physics: physicsOptions(false) })
|
||||||
|
await nextTick()
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||||
|
resizeNetworkCanvas()
|
||||||
|
|
||||||
|
const lockedPositions = snapshot?.positions
|
||||||
|
|| readGraphLayoutCache().positions
|
||||||
|
|| {}
|
||||||
|
|
||||||
if (syncedRevision !== store.dataRevision) {
|
if (syncedRevision !== store.dataRevision) {
|
||||||
await ensureGraphReady({ showSpinner: false })
|
await applyGraphDataFromStore()
|
||||||
|
syncGraphMetadataOnly(lockedPositions)
|
||||||
|
syncedRevision = store.dataRevision
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot) {
|
||||||
|
applyLayoutSnapshot(snapshot)
|
||||||
} else {
|
} else {
|
||||||
restoreViewport()
|
restoreViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (physicsEnabled.value) {
|
||||||
|
network.value.setOptions({ physics: physicsOptions(true) })
|
||||||
|
}
|
||||||
|
network.value.redraw()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await ensureGraphReady()
|
await ensureGraphReady()
|
||||||
})
|
})
|
||||||
|
|
||||||
onDeactivated(() => {
|
onDeactivated(() => {
|
||||||
|
graphViewActive = false
|
||||||
|
if (network.value && physicsEnabled.value) {
|
||||||
|
network.value.setOptions({ physics: physicsOptions(false) })
|
||||||
|
}
|
||||||
|
if (!layoutSnapshotOnLeave) {
|
||||||
|
layoutSnapshotOnLeave = captureLayoutSnapshot()
|
||||||
|
}
|
||||||
saveLayoutSnapshot()
|
saveLayoutSnapshot()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -901,7 +1126,7 @@ onUnmounted(() => {
|
|||||||
saveLayoutSnapshot()
|
saveLayoutSnapshot()
|
||||||
closeContextMenu()
|
closeContextMenu()
|
||||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||||
if (document.fullscreenElement === graphArea.value) {
|
if (document.fullscreenElement === graphStack.value) {
|
||||||
document.exitFullscreen().catch(() => {})
|
document.exitFullscreen().catch(() => {})
|
||||||
}
|
}
|
||||||
themeObserver?.disconnect()
|
themeObserver?.disconnect()
|
||||||
@@ -917,15 +1142,6 @@ onUnmounted(() => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.graph-view-toolbar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 0 28px 10px;
|
|
||||||
}
|
|
||||||
.graph-plugin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
.graph-chrome-bar {
|
.graph-chrome-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -959,10 +1175,6 @@ onUnmounted(() => {
|
|||||||
.graph-view--chrome-collapsed .graph-area {
|
.graph-view--chrome-collapsed .graph-area {
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
.graph-link-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
.graph-area {
|
.graph-area {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -971,41 +1183,8 @@ onUnmounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0 28px 20px;
|
padding: 0 28px 20px;
|
||||||
}
|
}
|
||||||
.graph-area:fullscreen {
|
.graph-stack {
|
||||||
padding: 12px;
|
position: relative;
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
.graph-area:fullscreen #graph-container {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.graph-fullscreen-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 36px;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
.graph-fullscreen-btn:hover {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--surface-alt);
|
|
||||||
}
|
|
||||||
.graph-area:fullscreen .graph-fullscreen-btn {
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
}
|
|
||||||
#graph-container {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1014,4 +1193,54 @@ onUnmounted(() => {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.graph-stack:fullscreen {
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.graph-view-tools {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.graph-fit-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.graph-fullscreen-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.graph-fullscreen-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
.graph-stack:fullscreen .graph-view-tools {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
#graph-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+498
-191
@@ -3,135 +3,228 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Импорт и экспорт</h2>
|
<h2>Импорт и экспорт</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-content content-narrow">
|
<div class="page-content">
|
||||||
<div class="card">
|
<div class="card format-examples">
|
||||||
<h3 class="section-title">Загрузить файл</h3>
|
<h3 class="section-title">Поддерживаемые форматы</h3>
|
||||||
<p class="text-muted section-subtitle">
|
<p class="text-muted section-subtitle">
|
||||||
Поддерживаются форматы <strong style="color:var(--text)">CSV</strong>, <strong style="color:var(--text)">JSON</strong> и <strong style="color:var(--text)">vCard (.vcf)</strong>.
|
CSV, JSON и vCard (.vcf) для контактов; CSV и JSON для связей.
|
||||||
|
Полный бэкап приложения (контакты, связи, карты) — JSON в разделах локальной и удалённой базы.
|
||||||
</p>
|
</p>
|
||||||
|
<pre class="format-pre">name,email,phone,organization,position,notes
|
||||||
<!-- Format examples -->
|
|
||||||
<div class="card" style="background:var(--surface-alt);margin-bottom:18px;padding:14px;">
|
|
||||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px;">Пример CSV:</div>
|
|
||||||
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">name,email,phone,organization,position,notes
|
|
||||||
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
|
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
|
||||||
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример JSON:</div>
|
</div>
|
||||||
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">[{"name":"Иван Иванов","email":"ivan@example.com","organization":"ООО Ромашка"}]</pre>
|
|
||||||
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример vCard (.vcf):</div>
|
|
||||||
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">BEGIN:VCARD
|
|
||||||
FN:Иван Иванов
|
|
||||||
EMAIL:ivan@example.com
|
|
||||||
TEL:+79000000001
|
|
||||||
ORG:ООО Ромашка
|
|
||||||
END:VCARD</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Drop zone -->
|
<div class="import-grid">
|
||||||
<div
|
<!-- Local -->
|
||||||
class="drop-zone"
|
<div class="card import-panel">
|
||||||
:class="{ 'drag-over': isDragging }"
|
<div class="import-panel__head">
|
||||||
@dragover.prevent="isDragging = true"
|
<h3 class="section-title">Локальная база</h3>
|
||||||
@dragleave="isDragging = false"
|
<span class="import-badge">IndexedDB</span>
|
||||||
@drop.prevent="onDrop"
|
|
||||||
@click="$refs.fileInput.click()"
|
|
||||||
>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.4;margin:0 auto 10px;display:block;">
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
||||||
<polyline points="17 8 12 3 7 8"/>
|
|
||||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
|
||||||
</svg>
|
|
||||||
<div style="font-size:13px;color:var(--text-muted);">
|
|
||||||
{{ selectedFile ? selectedFile.name : 'Перетащите файл или нажмите для выбора' }}
|
|
||||||
</div>
|
</div>
|
||||||
<input ref="fileInput" type="file" accept=".csv,.json,.vcf,.vcard" style="display:none" @change="onFileSelect" />
|
<p class="text-muted section-subtitle">
|
||||||
</div>
|
Данные в браузере. Не зависит от выбранного режима в настройках.
|
||||||
|
<span v-if="isLocalMode" class="import-mode-hint">Сейчас активен локальный режим.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:16px;">
|
<ImportFileBlock
|
||||||
<span v-if="result.error">{{ result.error }}</span>
|
v-model:file="localFile"
|
||||||
<span v-else>
|
:importing="localImporting"
|
||||||
В файле: <strong>{{ result.total ?? result.created + result.skipped }}</strong>,
|
:result="localResult"
|
||||||
импортировано: <strong>{{ result.created }}</strong> контактов<template v-if="result.importedRelations">, <strong>{{ result.importedRelations }}</strong> связей</template>,
|
import-label="Импортировать в локальную БД"
|
||||||
пропущено: {{ result.skipped }}.
|
@import="doLocalImport"
|
||||||
<span v-if="result.errors?.length"> Ошибок: {{ result.errors.length }}.</span>
|
@reset="resetLocal"
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="result?.errors?.length" style="margin-top:8px;">
|
|
||||||
<div v-for="e in result.errors" :key="e" class="text-muted" style="font-size:12px;">{{ e }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:16px;">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="!selectedFile || importing"
|
|
||||||
@click="doImport"
|
|
||||||
>
|
|
||||||
<svg v-if="importing" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin .7s linear infinite;">
|
|
||||||
<path d="M21 12a9 9 0 1 1-6.22-8.56"/>
|
|
||||||
</svg>
|
|
||||||
{{ importing ? 'Импорт...' : 'Импортировать' }}
|
|
||||||
</button>
|
|
||||||
<button v-if="selectedFile" class="btn btn-secondary" style="margin-left:8px;" @click="reset">Сбросить</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border);" />
|
|
||||||
<h3 class="section-title">Экспорт контактов</h3>
|
|
||||||
<p class="text-muted section-subtitle">
|
|
||||||
Скачать все контакты в выбранном формате: <strong style="color:var(--text)">CSV</strong>,
|
|
||||||
<strong style="color:var(--text)">JSON</strong> или <strong style="color:var(--text)">vCard (.vcf)</strong>
|
|
||||||
— совместимо с Nextcloud и другими адресными книгами.
|
|
||||||
</p>
|
|
||||||
<div class="export-row">
|
|
||||||
<div class="form-group" style="margin-bottom:0;flex:1;">
|
|
||||||
<label for="export-format">Формат файла</label>
|
|
||||||
<select id="export-format" v-model="exportFormat" class="form-control">
|
|
||||||
<option value="csv">CSV (.csv)</option>
|
|
||||||
<option value="json">JSON (.json)</option>
|
|
||||||
<option value="vcf">vCard (.vcf)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="exporting || store.totalContacts === 0"
|
|
||||||
@click="doExportContacts"
|
|
||||||
>
|
|
||||||
{{ exporting ? 'Экспорт...' : 'Экспортировать' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="store.totalContacts === 0" class="text-muted" style="font-size:12px;margin-top:8px;">
|
|
||||||
Нет контактов для экспорта.
|
|
||||||
</p>
|
|
||||||
<div v-if="exportResult" class="alert" :class="exportResult.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
|
|
||||||
<span v-if="exportResult.error">{{ exportResult.error }}</span>
|
|
||||||
<span v-else>
|
|
||||||
Экспортировано контактов: <strong>{{ exportResult.count }}</strong>
|
|
||||||
({{ exportResult.formatLabel }}).
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border);" />
|
|
||||||
<h3 class="section-title">Бэкап локальной базы</h3>
|
|
||||||
<p class="text-muted section-subtitle">
|
|
||||||
Экспортирует/импортирует локальные данные. Пароль для шифрования необязателен.
|
|
||||||
</p>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Пароль шифрования (необязательно)</label>
|
|
||||||
<input v-model="backupPassphrase" type="password" class="form-control" placeholder="Оставьте пустым для обычного JSON" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-secondary" :disabled="busyBackup" @click="doExport">
|
|
||||||
{{ busyBackup ? 'Экспорт...' : 'Экспорт локальной БД' }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" style="margin-left:8px;" :disabled="busyBackup" @click="$refs.backupInput.click()">
|
|
||||||
Импорт бэкапа
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref="backupInput"
|
|
||||||
type="file"
|
|
||||||
accept=".json,.sgpkg"
|
|
||||||
style="display:none"
|
|
||||||
@change="onBackupFileSelect"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Экспорт контактов</h4>
|
||||||
|
<ExportFormatRow
|
||||||
|
v-model="localExportFormat"
|
||||||
|
:exporting="localExporting"
|
||||||
|
:disabled="localContactCount === 0"
|
||||||
|
:result="localExportResult"
|
||||||
|
button-label="Экспортировать локально"
|
||||||
|
@export="doLocalExport"
|
||||||
|
/>
|
||||||
|
<p v-if="localContactCount === 0" class="text-muted import-empty-hint">
|
||||||
|
В локальной базе нет контактов.
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-muted import-empty-hint">
|
||||||
|
Контактов в локальной базе: {{ localContactCount }}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Экспорт связей</h4>
|
||||||
|
<ExportFormatRow
|
||||||
|
v-model="localRelationsExportFormat"
|
||||||
|
variant="relations"
|
||||||
|
:exporting="localRelationsExporting"
|
||||||
|
:disabled="localRelationCount === 0"
|
||||||
|
:result="localRelationsExportResult"
|
||||||
|
button-label="Экспортировать связи локально"
|
||||||
|
@export="doLocalRelationsExport"
|
||||||
|
/>
|
||||||
|
<p v-if="localRelationCount === 0" class="text-muted import-empty-hint">
|
||||||
|
В локальной базе нет связей.
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-muted import-empty-hint">
|
||||||
|
Связей в локальной базе: {{ localRelationCount }}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Полный бэкап</h4>
|
||||||
|
<p class="text-muted section-subtitle">
|
||||||
|
Экспорт или восстановление всей локальной базы (контакты, связи, карты). Пароль необязателен.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль шифрования (необязательно)</label>
|
||||||
|
<input v-model="backupPassphrase" type="password" class="form-control" placeholder="Оставьте пустым для обычного JSON" />
|
||||||
|
</div>
|
||||||
|
<div class="import-actions">
|
||||||
|
<button class="btn btn-secondary" type="button" :disabled="busyBackup" @click="doExportBackup">
|
||||||
|
{{ busyBackup ? 'Экспорт...' : 'Экспорт бэкапа' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" type="button" :disabled="busyBackup" @click="$refs.backupInput.click()">
|
||||||
|
Импорт бэкапа
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="backupInput"
|
||||||
|
type="file"
|
||||||
|
accept=".json,.sgpkg"
|
||||||
|
style="display:none"
|
||||||
|
@change="onBackupFileSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remote -->
|
||||||
|
<div class="card import-panel">
|
||||||
|
<div class="import-panel__head">
|
||||||
|
<h3 class="section-title">Удалённая база</h3>
|
||||||
|
<span class="import-badge import-badge--remote">Сервер</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted section-subtitle">
|
||||||
|
Django API. Нужен вход в аккаунт.
|
||||||
|
<span v-if="isRemoteMode" class="import-mode-hint">Сейчас активен серверный режим.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="serverProbeLoading" class="text-muted server-status">Проверка сервера…</p>
|
||||||
|
<p v-else-if="serverProbe.ok" class="server-status server-status--ok">
|
||||||
|
Сервер доступен · контактов: {{ serverProbe.contactCount }} · связей: {{ serverProbe.relationCount }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="alert alert-error server-status">{{ serverProbe.message }}</p>
|
||||||
|
<p v-if="!isAuthenticated" class="alert alert-error server-status">
|
||||||
|
Войдите в аккаунт для работы с серверной базой.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ImportFileBlock
|
||||||
|
v-model:file="remoteFile"
|
||||||
|
:importing="remoteImporting"
|
||||||
|
:result="remoteResult"
|
||||||
|
:disabled="!canUseRemote"
|
||||||
|
import-label="Импортировать на сервер"
|
||||||
|
@import="doRemoteImport"
|
||||||
|
@reset="resetRemote"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Экспорт контактов</h4>
|
||||||
|
<ExportFormatRow
|
||||||
|
v-model="remoteExportFormat"
|
||||||
|
:exporting="remoteExporting"
|
||||||
|
:disabled="!canUseRemote || serverProbe.contactCount === 0"
|
||||||
|
:result="remoteExportResult"
|
||||||
|
button-label="Экспортировать с сервера"
|
||||||
|
@export="doRemoteExport"
|
||||||
|
/>
|
||||||
|
<p v-if="canUseRemote && serverProbe.contactCount === 0" class="text-muted import-empty-hint">
|
||||||
|
На сервере нет контактов.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Экспорт связей</h4>
|
||||||
|
<ExportFormatRow
|
||||||
|
v-model="remoteRelationsExportFormat"
|
||||||
|
variant="relations"
|
||||||
|
:exporting="remoteRelationsExporting"
|
||||||
|
:disabled="!canUseRemote || serverProbe.relationCount === 0"
|
||||||
|
:result="remoteRelationsExportResult"
|
||||||
|
button-label="Экспортировать связи с сервера"
|
||||||
|
@export="doRemoteRelationsExport"
|
||||||
|
/>
|
||||||
|
<p v-if="canUseRemote && serverProbe.relationCount === 0" class="text-muted import-empty-hint">
|
||||||
|
На сервере нет связей.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Полный бэкап</h4>
|
||||||
|
<p class="text-muted section-subtitle">
|
||||||
|
Экспорт или восстановление всей серверной базы (контакты, связи, карты).
|
||||||
|
При импорте с заменой существующие данные аккаунта удаляются.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input v-model="remoteBackupReplace" type="checkbox" />
|
||||||
|
Заменить данные на сервере при импорте
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль (только для зашифрованного .sgpkg)</label>
|
||||||
|
<input
|
||||||
|
v-model="remoteBackupPassphrase"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Оставьте пустым для обычного JSON"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="import-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canUseRemote || busyRemoteBackup"
|
||||||
|
@click="doExportRemoteBackup"
|
||||||
|
>
|
||||||
|
{{ busyRemoteBackup ? 'Экспорт...' : 'Экспорт бэкапа' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canUseRemote || busyRemoteBackup"
|
||||||
|
@click="$refs.remoteBackupInput.click()"
|
||||||
|
>
|
||||||
|
Импорт бэкапа
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="remoteBackupInput"
|
||||||
|
type="file"
|
||||||
|
accept=".json,.sgpkg"
|
||||||
|
style="display:none"
|
||||||
|
@change="onRemoteBackupFileSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="remoteBackupResult"
|
||||||
|
class="alert"
|
||||||
|
:class="remoteBackupResult.error ? 'alert-error' : 'alert-success'"
|
||||||
|
style="margin-top:12px;"
|
||||||
|
>
|
||||||
|
<span v-if="remoteBackupResult.error">{{ remoteBackupResult.error }}</span>
|
||||||
|
<span v-else>
|
||||||
|
Импортировано контактов: <strong>{{ remoteBackupResult.importedContacts }}</strong>,
|
||||||
|
связей: <strong>{{ remoteBackupResult.importedRelations }}</strong>,
|
||||||
|
карт: <strong>{{ remoteBackupResult.importedMaps }}</strong>.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted migration-link">
|
||||||
|
Перенос между локальной и серверной базой — в
|
||||||
|
<RouterLink to="/settings">настройках</RouterLink>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,22 +232,52 @@ END:VCARD</pre>
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
import { useContactsStore } from '../stores/contacts'
|
import { useContactsStore } from '../stores/contacts'
|
||||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||||
|
import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
|
||||||
|
import { getStoredAccessToken } from '../stores/auth'
|
||||||
|
import { probeRemoteServer } from '../application/usecases/dataMigration'
|
||||||
|
import { localContactRepository } from '../infrastructure/repositories/contactRepository.local'
|
||||||
|
import { localRelationRepository } from '../infrastructure/repositories/relationRepository.local'
|
||||||
|
import ImportFileBlock from '../components/import/ImportFileBlock.vue'
|
||||||
|
import ExportFormatRow from '../components/import/ExportFormatRow.vue'
|
||||||
|
|
||||||
const store = useContactsStore()
|
const store = useContactsStore()
|
||||||
const mapsStore = useNetworkMapsStore()
|
const mapsStore = useNetworkMapsStore()
|
||||||
const fileInput = ref(null)
|
|
||||||
const selectedFile = ref(null)
|
const localFile = ref(null)
|
||||||
const isDragging = ref(false)
|
const remoteFile = ref(null)
|
||||||
const importing = ref(false)
|
const localImporting = ref(false)
|
||||||
const result = ref(null)
|
const remoteImporting = ref(false)
|
||||||
|
const localResult = ref(null)
|
||||||
|
const remoteResult = ref(null)
|
||||||
const backupPassphrase = ref('')
|
const backupPassphrase = ref('')
|
||||||
|
const remoteBackupPassphrase = ref('')
|
||||||
|
const remoteBackupReplace = ref(true)
|
||||||
const busyBackup = ref(false)
|
const busyBackup = ref(false)
|
||||||
const exportFormat = ref('csv')
|
const busyRemoteBackup = ref(false)
|
||||||
const exporting = ref(false)
|
const remoteBackupResult = ref(null)
|
||||||
const exportResult = ref(null)
|
const localExportFormat = ref('csv')
|
||||||
|
const remoteExportFormat = ref('csv')
|
||||||
|
const localRelationsExportFormat = ref('csv')
|
||||||
|
const remoteRelationsExportFormat = ref('csv')
|
||||||
|
const localExporting = ref(false)
|
||||||
|
const remoteExporting = ref(false)
|
||||||
|
const localRelationsExporting = ref(false)
|
||||||
|
const remoteRelationsExporting = ref(false)
|
||||||
|
const localExportResult = ref(null)
|
||||||
|
const remoteExportResult = ref(null)
|
||||||
|
const localRelationsExportResult = ref(null)
|
||||||
|
const remoteRelationsExportResult = ref(null)
|
||||||
|
const localContactCount = ref(0)
|
||||||
|
const localRelationCount = ref(0)
|
||||||
|
const serverProbeLoading = ref(true)
|
||||||
|
const serverProbe = ref({ ok: false, message: '', contactCount: 0, relationCount: 0 })
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => Boolean(getStoredAccessToken()))
|
||||||
|
const canUseRemote = computed(() => isAuthenticated.value && serverProbe.value.ok)
|
||||||
|
|
||||||
const exportFormatLabels = {
|
const exportFormatLabels = {
|
||||||
csv: 'CSV',
|
csv: 'CSV',
|
||||||
@@ -162,71 +285,158 @@ const exportFormatLabels = {
|
|||||||
vcf: 'vCard',
|
vcf: 'vCard',
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFileSelect(e) {
|
async function refreshLocalCount() {
|
||||||
selectedFile.value = e.target.files[0] || null
|
const [contacts, relations] = await Promise.all([
|
||||||
result.value = null
|
localContactRepository.list(),
|
||||||
|
localRelationRepository.list(),
|
||||||
|
])
|
||||||
|
localContactCount.value = contacts.length
|
||||||
|
localRelationCount.value = relations.length
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrop(e) {
|
async function refreshServerProbe() {
|
||||||
isDragging.value = false
|
serverProbeLoading.value = true
|
||||||
const file = e.dataTransfer.files[0]
|
serverProbe.value = await probeRemoteServer()
|
||||||
if (file) { selectedFile.value = file; result.value = null }
|
serverProbeLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doImport() {
|
onMounted(async () => {
|
||||||
if (!selectedFile.value) return
|
await Promise.all([refreshLocalCount(), refreshServerProbe()])
|
||||||
importing.value = true
|
})
|
||||||
result.value = null
|
|
||||||
|
watch(localFile, () => {
|
||||||
|
localResult.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(remoteFile, () => {
|
||||||
|
remoteResult.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetLocal() {
|
||||||
|
localFile.value = null
|
||||||
|
localResult.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRemote() {
|
||||||
|
remoteFile.value = null
|
||||||
|
remoteResult.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLocalImport() {
|
||||||
|
if (!localFile.value) return
|
||||||
|
localImporting.value = true
|
||||||
|
localResult.value = null
|
||||||
try {
|
try {
|
||||||
result.value = await store.importContacts(selectedFile.value)
|
localResult.value = await store.importContactsToLocal(localFile.value)
|
||||||
if (result.value.isDump) {
|
if (localResult.value.isDump) {
|
||||||
await mapsStore.fetchMaps()
|
await mapsStore.fetchMaps()
|
||||||
}
|
}
|
||||||
|
await refreshLocalCount()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result.value = { error: e.message }
|
localResult.value = { error: e.message }
|
||||||
} finally {
|
} finally {
|
||||||
importing.value = false
|
localImporting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
async function doRemoteImport() {
|
||||||
selectedFile.value = null
|
if (!remoteFile.value || !canUseRemote.value) return
|
||||||
result.value = null
|
remoteImporting.value = true
|
||||||
if (fileInput.value) fileInput.value.value = ''
|
remoteResult.value = null
|
||||||
|
try {
|
||||||
|
remoteResult.value = await store.importContactsToRemote(remoteFile.value)
|
||||||
|
await refreshServerProbe()
|
||||||
|
} catch (e) {
|
||||||
|
remoteResult.value = { error: e.message }
|
||||||
|
} finally {
|
||||||
|
remoteImporting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doExportContacts() {
|
async function doLocalExport() {
|
||||||
exporting.value = true
|
localExporting.value = true
|
||||||
exportResult.value = null
|
localExportResult.value = null
|
||||||
try {
|
try {
|
||||||
const { blob, filename, count, format } = await store.exportContacts(exportFormat.value)
|
const { blob, filename, count, format } = await store.exportLocalContacts(localExportFormat.value)
|
||||||
const url = URL.createObjectURL(blob)
|
downloadBlob(blob, filename)
|
||||||
const link = document.createElement('a')
|
localExportResult.value = {
|
||||||
link.href = url
|
|
||||||
link.download = filename
|
|
||||||
link.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
exportResult.value = {
|
|
||||||
count,
|
count,
|
||||||
formatLabel: exportFormatLabels[format] || format,
|
formatLabel: exportFormatLabels[format] || format,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
exportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
localExportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||||
} finally {
|
} finally {
|
||||||
exporting.value = false
|
localExporting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doExport() {
|
async function doRemoteExport() {
|
||||||
|
if (!canUseRemote.value) return
|
||||||
|
remoteExporting.value = true
|
||||||
|
remoteExportResult.value = null
|
||||||
|
try {
|
||||||
|
const { blob, filename, count, format } = await store.exportRemoteContacts(remoteExportFormat.value)
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
remoteExportResult.value = {
|
||||||
|
count,
|
||||||
|
formatLabel: exportFormatLabels[format] || format,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
remoteExportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||||
|
} finally {
|
||||||
|
remoteExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLocalRelationsExport() {
|
||||||
|
localRelationsExporting.value = true
|
||||||
|
localRelationsExportResult.value = null
|
||||||
|
try {
|
||||||
|
const { blob, filename, count, format } = await store.exportLocalRelations(localRelationsExportFormat.value)
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
localRelationsExportResult.value = {
|
||||||
|
count,
|
||||||
|
formatLabel: exportFormatLabels[format] || format,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
localRelationsExportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||||
|
} finally {
|
||||||
|
localRelationsExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRemoteRelationsExport() {
|
||||||
|
if (!canUseRemote.value) return
|
||||||
|
remoteRelationsExporting.value = true
|
||||||
|
remoteRelationsExportResult.value = null
|
||||||
|
try {
|
||||||
|
const { blob, filename, count, format } = await store.exportRemoteRelations(remoteRelationsExportFormat.value)
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
remoteRelationsExportResult.value = {
|
||||||
|
count,
|
||||||
|
formatLabel: exportFormatLabels[format] || format,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
remoteRelationsExportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||||
|
} finally {
|
||||||
|
remoteRelationsExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doExportBackup() {
|
||||||
busyBackup.value = true
|
busyBackup.value = true
|
||||||
try {
|
try {
|
||||||
const { blob, filename } = await store.exportData(backupPassphrase.value)
|
const { blob, filename } = await store.exportData(backupPassphrase.value)
|
||||||
const url = URL.createObjectURL(blob)
|
downloadBlob(blob, filename)
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = filename
|
|
||||||
link.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
} finally {
|
} finally {
|
||||||
busyBackup.value = false
|
busyBackup.value = false
|
||||||
}
|
}
|
||||||
@@ -236,11 +446,11 @@ async function onBackupFileSelect(e) {
|
|||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
busyBackup.value = true
|
busyBackup.value = true
|
||||||
result.value = null
|
localResult.value = null
|
||||||
try {
|
try {
|
||||||
const summary = await store.importDataDump(file, backupPassphrase.value)
|
const summary = await store.importDataDump(file, backupPassphrase.value)
|
||||||
await mapsStore.fetchMaps()
|
await mapsStore.fetchMaps()
|
||||||
result.value = {
|
localResult.value = {
|
||||||
total: summary.importedContacts + summary.importedRelations,
|
total: summary.importedContacts + summary.importedRelations,
|
||||||
created: summary.importedContacts,
|
created: summary.importedContacts,
|
||||||
importedRelations: summary.importedRelations,
|
importedRelations: summary.importedRelations,
|
||||||
@@ -248,42 +458,139 @@ async function onBackupFileSelect(e) {
|
|||||||
errors: [],
|
errors: [],
|
||||||
isDump: true,
|
isDump: true,
|
||||||
}
|
}
|
||||||
|
await refreshLocalCount()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.value = { error: error?.message || 'Ошибка импорта бэкапа' }
|
localResult.value = { error: error?.message || 'Ошибка импорта бэкапа' }
|
||||||
} finally {
|
} finally {
|
||||||
busyBackup.value = false
|
busyBackup.value = false
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doExportRemoteBackup() {
|
||||||
|
if (!canUseRemote.value) return
|
||||||
|
busyRemoteBackup.value = true
|
||||||
|
remoteBackupResult.value = null
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await store.exportRemoteData()
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
} catch (error) {
|
||||||
|
remoteBackupResult.value = { error: error?.message || 'Ошибка экспорта бэкапа' }
|
||||||
|
} finally {
|
||||||
|
busyRemoteBackup.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemoteBackupFileSelect(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
busyRemoteBackup.value = true
|
||||||
|
remoteBackupResult.value = null
|
||||||
|
try {
|
||||||
|
const summary = await store.importRemoteDataDump(file, {
|
||||||
|
replace: remoteBackupReplace.value,
|
||||||
|
passphrase: remoteBackupPassphrase.value,
|
||||||
|
})
|
||||||
|
await mapsStore.fetchMaps()
|
||||||
|
remoteBackupResult.value = summary
|
||||||
|
await refreshServerProbe()
|
||||||
|
} catch (error) {
|
||||||
|
remoteBackupResult.value = { error: error?.message || 'Ошибка импорта бэкапа' }
|
||||||
|
} finally {
|
||||||
|
busyRemoteBackup.value = false
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.content-narrow {
|
.format-examples {
|
||||||
max-width: 640px;
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.format-pre {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--green);
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.import-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.import-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.import-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.import-badge--remote {
|
||||||
|
background: rgba(78, 204, 163, 0.12);
|
||||||
|
color: var(--green, #4ecca3);
|
||||||
}
|
}
|
||||||
.section-title {
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.subsection-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 6px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
.section-subtitle {
|
.section-subtitle {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
.drop-zone {
|
.import-mode-hint {
|
||||||
border: 2px dashed var(--border);
|
display: block;
|
||||||
border-radius: var(--radius);
|
margin-top: 4px;
|
||||||
padding: 36px 20px;
|
color: var(--accent);
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, background 0.15s;
|
|
||||||
}
|
}
|
||||||
.drop-zone:hover, .drag-over {
|
.import-divider {
|
||||||
border-color: var(--accent);
|
margin: 20px 0;
|
||||||
background: var(--accent-dim);
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.export-row {
|
.import-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.import-empty-hint,
|
||||||
|
.migration-link {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.server-status {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.server-status--ok {
|
||||||
|
color: var(--green, #4ecca3);
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.import-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
:collapsed="topPanelCollapsed"
|
:collapsed="topPanelCollapsed"
|
||||||
:title="activeMap?.name || 'Карта сети'"
|
:title="activeMap?.name || 'Карта сети'"
|
||||||
:subtitle="mapSubtitle"
|
:subtitle="mapSubtitle"
|
||||||
@toggle-collapse="topPanelCollapsed = !topPanelCollapsed"
|
:show-legend="isConflictology && !loading && nodes.length > 0"
|
||||||
@fit="fitView"
|
@toggle-collapse="toggleTopPanel"
|
||||||
>
|
>
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<NetworkMapSwitcher
|
<NetworkMapSwitcher
|
||||||
@@ -15,24 +15,9 @@
|
|||||||
@create="openCreateMap"
|
@create="openCreateMap"
|
||||||
@manage="openEditMap"
|
@manage="openEditMap"
|
||||||
/>
|
/>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" @click="openAddContact">
|
|
||||||
+ Участник
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
<template #legend>
|
<template #legend>
|
||||||
<p v-if="!loading && nodes.length > 0" class="map-link-hint text-muted">
|
<p class="conflict-legend text-muted">
|
||||||
<template v-if="isConflictology">
|
|
||||||
В центре — предмет конфликта. Размер точки — вовлечённость. Стрелки: давление → жертва.
|
|
||||||
Ctrl+клик по двум участникам — добавить связь.
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
Ctrl+клик (⌘+клик) по двум контактам — создать связь.
|
|
||||||
</template>
|
|
||||||
<span v-if="linkSelectionCount === 1">
|
|
||||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p v-if="!loading && nodes.length > 0 && isConflictology" class="conflict-legend text-muted">
|
|
||||||
<span class="legend-item legend-open">● Открытый конфликт</span>
|
<span class="legend-item legend-open">● Открытый конфликт</span>
|
||||||
<span class="legend-item legend-tension">- - Напряжение</span>
|
<span class="legend-item legend-tension">- - Напряжение</span>
|
||||||
<span class="legend-item legend-alliance">● Союз</span>
|
<span class="legend-item legend-alliance">● Союз</span>
|
||||||
@@ -56,6 +41,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="map-stack" ref="mapStack">
|
<div v-else class="map-stack" ref="mapStack">
|
||||||
|
<div class="map-view-tools">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="map-fit-btn btn btn-secondary btn-sm"
|
||||||
|
title="По центру"
|
||||||
|
@click="fitView"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||||
|
</svg>
|
||||||
|
По центру
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="map-fullscreen-btn"
|
||||||
|
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
|
||||||
|
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
|
||||||
|
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||||
|
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 14h6v6"/>
|
||||||
|
<path d="M20 10h-6V4"/>
|
||||||
|
<path d="M14 10l7-7"/>
|
||||||
|
<path d="M3 21l7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="network-map-container" ref="graphContainer" class="map-vis"></div>
|
<div id="network-map-container" ref="graphContainer" class="map-vis"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +120,6 @@
|
|||||||
:x="contextMenuX"
|
:x="contextMenuX"
|
||||||
:y="contextMenuY"
|
:y="contextMenuY"
|
||||||
@close="closeContextMenu"
|
@close="closeContextMenu"
|
||||||
@info="openNodeInfo"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GraphEdgeContextMenu
|
<GraphEdgeContextMenu
|
||||||
@@ -119,13 +135,18 @@
|
|||||||
:open="canvasContextMenuOpen"
|
:open="canvasContextMenuOpen"
|
||||||
:x="canvasContextMenuX"
|
:x="canvasContextMenuX"
|
||||||
:y="canvasContextMenuY"
|
:y="canvasContextMenuY"
|
||||||
|
:actions="mapCanvasMenuActions"
|
||||||
@close="closeContextMenu"
|
@close="closeContextMenu"
|
||||||
@create-contact="openCreateContact"
|
@select="onCanvasMenuSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateContactModal
|
<CreateContactModal
|
||||||
:open="createContactOpen"
|
:open="createContactOpen"
|
||||||
:initial-map-ids="createContactMapIds"
|
:initial-map-ids="createContactMapIds"
|
||||||
|
:show-relation-link="mapMemberLinkTargets.length > 0"
|
||||||
|
:link-to-options="mapMemberLinkTargets"
|
||||||
|
:initial-link-to-id="linkSelection[0]?.id"
|
||||||
|
:conflict-mode="isConflictology"
|
||||||
@close="createContactOpen = false"
|
@close="createContactOpen = false"
|
||||||
@created="onContactCreated"
|
@created="onContactCreated"
|
||||||
/>
|
/>
|
||||||
@@ -162,6 +183,9 @@
|
|||||||
<AddContactToMapModal
|
<AddContactToMapModal
|
||||||
:open="showAddContact"
|
:open="showAddContact"
|
||||||
:member-contact-ids="memberContactIds"
|
:member-contact-ids="memberContactIds"
|
||||||
|
:link-targets="mapMemberLinkTargets"
|
||||||
|
:initial-link-to-id="linkSelection[0]?.id"
|
||||||
|
:conflict-mode="isConflictology"
|
||||||
:on-add="onAddContactToMap"
|
:on-add="onAddContactToMap"
|
||||||
@close="showAddContact = false"
|
@close="showAddContact = false"
|
||||||
/>
|
/>
|
||||||
@@ -169,8 +193,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, onActivated, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
import { Network, DataSet } from 'vis-network/standalone'
|
import { Network, DataSet } from 'vis-network/standalone'
|
||||||
import { useContactsStore } from '../stores/contacts'
|
import { useContactsStore } from '../stores/contacts'
|
||||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||||
@@ -193,6 +217,7 @@ import {
|
|||||||
import { fetchGraphBundle } from '../composables/useGraphData'
|
import { fetchGraphBundle } from '../composables/useGraphData'
|
||||||
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
|
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
|
||||||
import { edgeFromRelation } from '../application/usecases/graph'
|
import { edgeFromRelation } from '../application/usecases/graph'
|
||||||
|
import { readMapLayoutCache, writeMapLayoutCache } from '../lib/map/mapLayoutCache'
|
||||||
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
||||||
import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue'
|
import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue'
|
||||||
import NetworkMapFormModal from '../components/NetworkMapFormModal.vue'
|
import NetworkMapFormModal from '../components/NetworkMapFormModal.vue'
|
||||||
@@ -204,6 +229,7 @@ import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
|
|||||||
import CreateContactModal from '../components/CreateContactModal.vue'
|
import CreateContactModal from '../components/CreateContactModal.vue'
|
||||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||||
|
import { loadTopPanelCollapsed, saveTopPanelCollapsed } from '../lib/ui/topPanelCollapseStorage'
|
||||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||||
import { useNetworkMapTypesStore } from '../stores/networkMapTypes'
|
import { useNetworkMapTypesStore } from '../stores/networkMapTypes'
|
||||||
|
|
||||||
@@ -231,9 +257,17 @@ const {
|
|||||||
|
|
||||||
const createContactOpen = ref(false)
|
const createContactOpen = ref(false)
|
||||||
|
|
||||||
function openNodeInfo(node) {
|
const mapCanvasMenuActions = [
|
||||||
selectedNode.value = node || null
|
{ id: 'add-participant', label: 'Добавить участника' },
|
||||||
selectedInvolvement.value = Number(node?.conflict_involvement) || 3
|
{ id: 'create-contact', label: 'Добавить контакт' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function onCanvasMenuSelect(actionId) {
|
||||||
|
if (actionId === 'add-participant') {
|
||||||
|
openAddContact()
|
||||||
|
} else if (actionId === 'create-contact') {
|
||||||
|
openCreateContact()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateContact() {
|
function openCreateContact() {
|
||||||
@@ -242,11 +276,12 @@ function openCreateContact() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMapBodyContextMenu(event) {
|
function onMapBodyContextMenu(event) {
|
||||||
if (loading.value || nodes.value.length > 0) return
|
if (loading.value) return
|
||||||
|
if (nodes.value.length > 0) return
|
||||||
openCanvasContextMenu(event)
|
openCanvasContextMenu(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onContactCreated(data, mapIds, pluginPayload) {
|
async function onContactCreated(data, mapIds, pluginPayload, relationLink) {
|
||||||
const created = await store.createContact(data)
|
const created = await store.createContact(data)
|
||||||
const targetMapIds = mapIds?.length
|
const targetMapIds = mapIds?.length
|
||||||
? mapIds
|
? mapIds
|
||||||
@@ -256,7 +291,19 @@ async function onContactCreated(data, mapIds, pluginPayload) {
|
|||||||
}
|
}
|
||||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||||
await saveContactPluginData(created.id, pluginPayload)
|
await saveContactPluginData(created.id, pluginPayload)
|
||||||
|
|
||||||
|
if (relationLink?.targetId && String(relationLink.targetId) !== String(created.id)) {
|
||||||
|
await store.createRelation({
|
||||||
|
source: created.id,
|
||||||
|
target: relationLink.targetId,
|
||||||
|
relation_type: relationLink.type,
|
||||||
|
description: relationLink.description || '',
|
||||||
|
interaction_intensity: relationLink.intensity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
createContactOpen.value = false
|
createContactOpen.value = false
|
||||||
|
clearLinkSelection()
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,11 +452,21 @@ const conflictSubject = ref('')
|
|||||||
const selectedInvolvement = ref(3)
|
const selectedInvolvement = ref(3)
|
||||||
const memberContactIds = computed(() => nodes.value.map((n) => String(n.id)))
|
const memberContactIds = computed(() => nodes.value.map((n) => String(n.id)))
|
||||||
|
|
||||||
|
const mapMemberLinkTargets = computed(() =>
|
||||||
|
nodes.value
|
||||||
|
.filter((n) => String(n.id) !== CONFLICT_CENTER_NODE_ID)
|
||||||
|
.map((n) => ({
|
||||||
|
value: String(n.id),
|
||||||
|
label: n.label || String(n.id),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
const mapFormOpen = ref(false)
|
const mapFormOpen = ref(false)
|
||||||
const mapFormTarget = ref({})
|
const mapFormTarget = ref({})
|
||||||
const showAddContact = ref(false)
|
const showAddContact = ref(false)
|
||||||
const mapStack = ref(null)
|
const mapStack = ref(null)
|
||||||
const graphContainer = ref(null)
|
const graphContainer = ref(null)
|
||||||
|
const isFullscreen = ref(false)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const network = ref(null)
|
const network = ref(null)
|
||||||
const selectedNode = ref(null)
|
const selectedNode = ref(null)
|
||||||
@@ -420,7 +477,6 @@ const editRelationTarget = ref(null)
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
linkSelection,
|
linkSelection,
|
||||||
linkSelectionCount,
|
|
||||||
clearLinkSelection,
|
clearLinkSelection,
|
||||||
handleCtrlPickNode,
|
handleCtrlPickNode,
|
||||||
} = useCtrlLinkSelection({
|
} = useCtrlLinkSelection({
|
||||||
@@ -448,6 +504,8 @@ const RING_FILL_COLORS = [
|
|||||||
let nodesDS = null
|
let nodesDS = null
|
||||||
let edgesDS = null
|
let edgesDS = null
|
||||||
let resizeObserver = null
|
let resizeObserver = null
|
||||||
|
let syncedMapRevision = -1
|
||||||
|
let mapLayoutSnapshotOnLeave = null
|
||||||
let initRetryTimer = null
|
let initRetryTimer = null
|
||||||
let initRetryCount = 0
|
let initRetryCount = 0
|
||||||
const INIT_RETRY_MAX = 40
|
const INIT_RETRY_MAX = 40
|
||||||
@@ -455,7 +513,12 @@ const INIT_RETRY_MAX = 40
|
|||||||
const selectedContact = computed(() =>
|
const selectedContact = computed(() =>
|
||||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||||
)
|
)
|
||||||
const topPanelCollapsed = ref(false)
|
const topPanelCollapsed = ref(loadTopPanelCollapsed('map'))
|
||||||
|
|
||||||
|
function toggleTopPanel() {
|
||||||
|
topPanelCollapsed.value = !topPanelCollapsed.value
|
||||||
|
saveTopPanelCollapsed('map', topPanelCollapsed.value)
|
||||||
|
}
|
||||||
|
|
||||||
function cssVar(name, fallback) {
|
function cssVar(name, fallback) {
|
||||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
@@ -528,6 +591,7 @@ function resolveRelationForEdit(edge) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditRelation(edge) {
|
function openEditRelation(edge) {
|
||||||
|
saveMapLayoutSnapshot()
|
||||||
editRelationTarget.value = resolveRelationForEdit(edge)
|
editRelationTarget.value = resolveRelationForEdit(edge)
|
||||||
editRelationOpen.value = true
|
editRelationOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -554,6 +618,10 @@ function updateGraphEdge(relation) {
|
|||||||
function onRelationUpdated(relation) {
|
function onRelationUpdated(relation) {
|
||||||
closeEditRelation()
|
closeEditRelation()
|
||||||
updateGraphEdge(relation)
|
updateGraphEdge(relation)
|
||||||
|
nextTick(() => {
|
||||||
|
refreshPositions()
|
||||||
|
restoreMapViewport()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeGraphEdge(relationId) {
|
function removeGraphEdge(relationId) {
|
||||||
@@ -565,6 +633,10 @@ function removeGraphEdge(relationId) {
|
|||||||
function onRelationDeleted(relationId) {
|
function onRelationDeleted(relationId) {
|
||||||
closeEditRelation()
|
closeEditRelation()
|
||||||
removeGraphEdge(relationId)
|
removeGraphEdge(relationId)
|
||||||
|
nextTick(() => {
|
||||||
|
refreshPositions()
|
||||||
|
restoreMapViewport()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeRelationModal() {
|
function closeRelationModal() {
|
||||||
@@ -667,6 +739,67 @@ function measureLayout() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveMapLayoutSnapshot() {
|
||||||
|
if (!network.value || !mapId.value) return
|
||||||
|
writeMapLayoutCache(mapId.value, {
|
||||||
|
positions: network.value.getPositions(),
|
||||||
|
scale: network.value.getScale(),
|
||||||
|
view: network.value.getViewPosition(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureMapLayoutSnapshot() {
|
||||||
|
if (!network.value) return null
|
||||||
|
const view = network.value.getViewPosition()
|
||||||
|
return {
|
||||||
|
positions: { ...network.value.getPositions() },
|
||||||
|
scale: network.value.getScale(),
|
||||||
|
view: view ? { x: view.x, y: view.y } : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMapLayoutSnapshot(snapshot) {
|
||||||
|
if (!network.value || !nodesDS || !snapshot) return
|
||||||
|
const updates = Object.entries(snapshot.positions || {})
|
||||||
|
.filter(([id]) => nodesDS.get(id))
|
||||||
|
.map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
|
||||||
|
if (updates.length) nodesDS.update(updates)
|
||||||
|
if (snapshot.view) {
|
||||||
|
network.value.moveTo({
|
||||||
|
position: snapshot.view,
|
||||||
|
scale: snapshot.scale || 1,
|
||||||
|
animation: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mapId.value) writeMapLayoutCache(mapId.value, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeMapNetworkCanvas() {
|
||||||
|
if (!network.value || !graphContainer.value) return
|
||||||
|
const { offsetWidth: w, offsetHeight: h } = graphContainer.value
|
||||||
|
if (w > 10 && h > 10) {
|
||||||
|
network.value.setSize(`${w}px`, `${h}px`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreMapViewport() {
|
||||||
|
if (!network.value) return
|
||||||
|
const cache = readMapLayoutCache(mapId.value)
|
||||||
|
if (!cache.view) return
|
||||||
|
network.value.moveTo({
|
||||||
|
position: cache.view,
|
||||||
|
scale: cache.scale || 1,
|
||||||
|
animation: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMapViewport() {
|
||||||
|
if (!network.value) return null
|
||||||
|
const view = network.value.getViewPosition()
|
||||||
|
if (!view) return null
|
||||||
|
return { view, scale: network.value.getScale() || 1 }
|
||||||
|
}
|
||||||
|
|
||||||
function filteredEdges() {
|
function filteredEdges() {
|
||||||
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||||
return edges.value.filter(
|
return edges.value.filter(
|
||||||
@@ -771,6 +904,7 @@ function initNetwork() {
|
|||||||
initRetryTimer = null
|
initRetryTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveMapLayoutSnapshot()
|
||||||
network.value?.destroy()
|
network.value?.destroy()
|
||||||
network.value = null
|
network.value = null
|
||||||
|
|
||||||
@@ -831,14 +965,25 @@ function initNetwork() {
|
|||||||
|
|
||||||
network.value.on('zoom', () => {
|
network.value.on('zoom', () => {
|
||||||
refreshLabelsByZoom()
|
refreshLabelsByZoom()
|
||||||
|
saveMapLayoutSnapshot()
|
||||||
})
|
})
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
network.value?.fit({ animation: false, padding: 56 })
|
const cache = readMapLayoutCache(mapId.value)
|
||||||
|
if (cache.view) {
|
||||||
|
restoreMapViewport()
|
||||||
|
} else {
|
||||||
|
network.value?.fit({ animation: false, padding: 56 })
|
||||||
|
saveMapLayoutSnapshot()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshPositions() {
|
function refreshPositions({ preserveView = true } = {}) {
|
||||||
|
const viewport = preserveView
|
||||||
|
? (currentMapViewport() || readMapLayoutCache(mapId.value))
|
||||||
|
: null
|
||||||
|
|
||||||
measureLayout()
|
measureLayout()
|
||||||
if (!nodesDS || !network.value) return
|
if (!nodesDS || !network.value) return
|
||||||
const L = layout.value
|
const L = layout.value
|
||||||
@@ -859,7 +1004,17 @@ function refreshPositions() {
|
|||||||
updates.unshift(buildCenterConflictNode(L))
|
updates.unshift(buildCenterConflictNode(L))
|
||||||
}
|
}
|
||||||
nodesDS.update(updates)
|
nodesDS.update(updates)
|
||||||
|
|
||||||
|
if (viewport?.view) {
|
||||||
|
network.value.moveTo({
|
||||||
|
position: viewport.view,
|
||||||
|
scale: viewport.scale || 1,
|
||||||
|
animation: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
network.value.redraw()
|
network.value.redraw()
|
||||||
|
saveMapLayoutSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -898,8 +1053,65 @@ function refreshEdges() {
|
|||||||
edgesDS.add(filteredEdges().map(mapEdgeToVis))
|
edgesDS.add(filteredEdges().map(mapEdgeToVis))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncMapDataFromStore(lockedPositions) {
|
||||||
|
if (!mapId.value || !network.value) return
|
||||||
|
const bundle = await fetchGraphBundle({ mapId: mapId.value })
|
||||||
|
edges.value = bundle.edges
|
||||||
|
const prevById = new Map(nodes.value.map((n) => [String(n.id), n]))
|
||||||
|
nodes.value = bundle.nodes.map((fresh) => {
|
||||||
|
const prev = prevById.get(String(fresh.id))
|
||||||
|
return {
|
||||||
|
...fresh,
|
||||||
|
map_angle: prev?.map_angle ?? fresh.map_angle,
|
||||||
|
map_radius_ratio: prev?.map_radius_ratio ?? fresh.map_radius_ratio,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
refreshEdges()
|
||||||
|
if (!nodesDS) return
|
||||||
|
const positions = lockedPositions || network.value.getPositions()
|
||||||
|
nodesDS.update(
|
||||||
|
nodes.value.map((n) => {
|
||||||
|
const id = String(n.id)
|
||||||
|
const pos = positions[id]
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: n.label || '',
|
||||||
|
title: [n.label, n.title].filter(Boolean).join('\n'),
|
||||||
|
size: participantNodeSize(n),
|
||||||
|
...(pos ? { x: pos.x, y: pos.y } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function fitView() {
|
function fitView() {
|
||||||
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
||||||
|
saveMapLayoutSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
const el = mapStack.value
|
||||||
|
if (!el) return
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement === el) {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
} else {
|
||||||
|
await el.requestFullscreen()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Browser may block fullscreen without user gesture.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFullscreenChange() {
|
||||||
|
isFullscreen.value = document.fullscreenElement === mapStack.value
|
||||||
|
nextTick(() => {
|
||||||
|
resizeMapNetworkCanvas()
|
||||||
|
network.value?.redraw()
|
||||||
|
if (isFullscreen.value) {
|
||||||
|
fitView()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveMapTypeLabels() {
|
async function resolveMapTypeLabels() {
|
||||||
@@ -947,9 +1159,11 @@ async function load() {
|
|||||||
nodesDS = null
|
nodesDS = null
|
||||||
edgesDS = null
|
edgesDS = null
|
||||||
}
|
}
|
||||||
|
syncedMapRevision = store.dataRevision
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAddContact() {
|
async function openAddContact() {
|
||||||
|
closeContextMenu()
|
||||||
showAddContact.value = true
|
showAddContact.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,9 +1217,19 @@ async function onMapDelete() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAddContactToMap(contactId) {
|
async function onAddContactToMap(contactId, relationLink) {
|
||||||
await mapsStore.addContactToMap(mapId.value, contactId)
|
await mapsStore.addContactToMap(mapId.value, contactId)
|
||||||
|
if (relationLink?.targetId && String(relationLink.targetId) !== String(contactId)) {
|
||||||
|
await store.createRelation({
|
||||||
|
source: contactId,
|
||||||
|
target: relationLink.targetId,
|
||||||
|
relation_type: relationLink.type,
|
||||||
|
description: relationLink.description || '',
|
||||||
|
interaction_intensity: relationLink.intensity,
|
||||||
|
})
|
||||||
|
}
|
||||||
showAddContact.value = false
|
showAddContact.value = false
|
||||||
|
clearLinkSelection()
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,10 +1252,37 @@ watch(mapId, async (next, prev) => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
||||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeRouteLeave(() => {
|
||||||
|
mapLayoutSnapshotOnLeave = captureMapLayoutSnapshot()
|
||||||
|
saveMapLayoutSnapshot()
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
|
const snapshot = mapLayoutSnapshotOnLeave
|
||||||
|
mapLayoutSnapshotOnLeave = null
|
||||||
|
|
||||||
if (network.value) {
|
if (network.value) {
|
||||||
|
await nextTick()
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||||
|
resizeMapNetworkCanvas()
|
||||||
|
|
||||||
|
const lockedPositions = snapshot?.positions
|
||||||
|
|| readMapLayoutCache(mapId.value).positions
|
||||||
|
|| {}
|
||||||
|
|
||||||
|
if (syncedMapRevision !== store.dataRevision) {
|
||||||
|
await syncMapDataFromStore(lockedPositions)
|
||||||
|
syncedMapRevision = store.dataRevision
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot) {
|
||||||
|
applyMapLayoutSnapshot(snapshot)
|
||||||
|
} else {
|
||||||
|
restoreMapViewport()
|
||||||
|
}
|
||||||
network.value.redraw()
|
network.value.redraw()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1041,14 +1292,26 @@ onActivated(async () => {
|
|||||||
const stack = mapStack.value
|
const stack = mapStack.value
|
||||||
if (stack && !resizeObserver) {
|
if (stack && !resizeObserver) {
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (editRelationOpen.value || relationModalOpen.value) return
|
||||||
refreshPositions()
|
refreshPositions()
|
||||||
network.value?.redraw()
|
|
||||||
})
|
})
|
||||||
resizeObserver.observe(stack)
|
resizeObserver.observe(stack)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
if (!mapLayoutSnapshotOnLeave) {
|
||||||
|
mapLayoutSnapshotOnLeave = captureMapLayoutSnapshot()
|
||||||
|
}
|
||||||
|
saveMapLayoutSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
saveMapLayoutSnapshot()
|
||||||
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
if (document.fullscreenElement === mapStack.value) {
|
||||||
|
document.exitFullscreen().catch(() => {})
|
||||||
|
}
|
||||||
if (initRetryTimer) clearTimeout(initRetryTimer)
|
if (initRetryTimer) clearTimeout(initRetryTimer)
|
||||||
detachContextHandler?.()
|
detachContextHandler?.()
|
||||||
closeContextMenu()
|
closeContextMenu()
|
||||||
@@ -1074,11 +1337,6 @@ onUnmounted(() => {
|
|||||||
padding: 8px 28px 20px;
|
padding: 8px 28px 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.map-link-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
margin: 0 0 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.map-stack {
|
.map-stack {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1089,6 +1347,50 @@ onUnmounted(() => {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.map-stack:fullscreen {
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.map-view-tools {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.map-fit-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.map-fullscreen-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.map-fullscreen-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
.map-stack:fullscreen .map-view-tools {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
.map-vis {
|
.map-vis {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user