Add network map UX and theme support.
Introduce network map view/components with filtering and positioning, expand contacts/map backend fields and migrations, and add a persistent light/dark theme toggle with graph label color updates for readability. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd social-graph
|
||||
docker-compose up --build
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- Frontend: http://localhost:5173
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='importance',
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=3,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(5),
|
||||
],
|
||||
verbose_name='Важность (1–5)',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='life_sphere',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('work', 'Работа'),
|
||||
('study', 'Учёба'),
|
||||
('hobby', 'Хобби'),
|
||||
('family', 'Семья'),
|
||||
('health', 'Здоровье'),
|
||||
('other', 'Другое'),
|
||||
],
|
||||
default='other',
|
||||
max_length=32,
|
||||
verbose_name='Сфера жизни',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='network_circle',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('support', 'Круг поддержки'),
|
||||
('productivity', 'Круг продуктивности'),
|
||||
('development', 'Круг развития'),
|
||||
],
|
||||
default='productivity',
|
||||
max_length=32,
|
||||
verbose_name='Круг сети',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relation',
|
||||
name='interaction_intensity',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('intense', 'Интенсивные контакты'),
|
||||
('sparse', 'Редкие контакты'),
|
||||
],
|
||||
default='intense',
|
||||
max_length=32,
|
||||
verbose_name='Интенсивность общения',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_map_true_for_existing(apps, schema_editor):
|
||||
Contact = apps.get_model('contacts', 'Contact')
|
||||
Contact.objects.all().update(include_on_network_map=True)
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0002_contact_importance_contact_life_sphere_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='include_on_network_map',
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Показывать на карте сети',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_map_true_for_existing, noop_reverse),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_map_false_for_all(apps, schema_editor):
|
||||
Contact = apps.get_model('contacts', 'Contact')
|
||||
Contact.objects.all().update(include_on_network_map=False)
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0003_contact_include_on_network_map'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_map_false_for_all, noop_reverse),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0004_contact_map_default_off'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='map_angle',
|
||||
field=models.FloatField(blank=True, null=True, verbose_name='Угол позиции на карте'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='map_radius_ratio',
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1)],
|
||||
verbose_name='Радиус позиции на карте (доля)',
|
||||
),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,28 @@
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
LIFE_SPHERES = [
|
||||
('work', 'Работа'),
|
||||
('study', 'Учёба'),
|
||||
('hobby', 'Хобби'),
|
||||
('family', 'Семья'),
|
||||
('health', 'Здоровье'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
NETWORK_CIRCLES = [
|
||||
('support', 'Круг поддержки'),
|
||||
('productivity', 'Круг продуктивности'),
|
||||
('development', 'Круг развития'),
|
||||
]
|
||||
|
||||
INTERACTION_INTENSITY = [
|
||||
('intense', 'Интенсивные контакты'),
|
||||
('sparse', 'Редкие контакты'),
|
||||
]
|
||||
|
||||
|
||||
class Contact(models.Model):
|
||||
"""Контакт в социальном графе."""
|
||||
|
||||
@@ -10,6 +32,34 @@ class Contact(models.Model):
|
||||
organization = models.CharField(max_length=255, blank=True, verbose_name='Организация')
|
||||
position = models.CharField(max_length=255, blank=True, verbose_name='Должность')
|
||||
notes = models.TextField(blank=True, verbose_name='Заметки')
|
||||
life_sphere = models.CharField(
|
||||
max_length=32,
|
||||
choices=LIFE_SPHERES,
|
||||
default='other',
|
||||
verbose_name='Сфера жизни',
|
||||
)
|
||||
network_circle = models.CharField(
|
||||
max_length=32,
|
||||
choices=NETWORK_CIRCLES,
|
||||
default='productivity',
|
||||
verbose_name='Круг сети',
|
||||
)
|
||||
importance = models.PositiveSmallIntegerField(
|
||||
default=3,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
||||
verbose_name='Важность (1–5)',
|
||||
)
|
||||
include_on_network_map = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Показывать на карте сети',
|
||||
)
|
||||
map_angle = models.FloatField(null=True, blank=True, verbose_name='Угол позиции на карте')
|
||||
map_radius_ratio = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(0), MaxValueValidator(1)],
|
||||
verbose_name='Радиус позиции на карте (доля)',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -54,6 +104,12 @@ class Relation(models.Model):
|
||||
verbose_name='Тип связи',
|
||||
)
|
||||
description = models.CharField(max_length=255, blank=True, verbose_name='Описание')
|
||||
interaction_intensity = models.CharField(
|
||||
max_length=32,
|
||||
choices=INTERACTION_INTENSITY,
|
||||
default='intense',
|
||||
verbose_name='Интенсивность общения',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -10,6 +10,9 @@ class ContactSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'name', 'email', 'phone',
|
||||
'organization', 'position', 'notes',
|
||||
'life_sphere', 'network_circle', 'importance',
|
||||
'include_on_network_map',
|
||||
'map_angle', 'map_radius_ratio',
|
||||
'created_at', 'updated_at', 'relations_count',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'relations_count']
|
||||
@@ -30,7 +33,8 @@ class RelationSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'source', 'source_name',
|
||||
'target', 'target_name',
|
||||
'relation_type', 'description', 'created_at',
|
||||
'relation_type', 'description', 'interaction_intensity',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'source_name', 'target_name']
|
||||
|
||||
@@ -56,6 +60,9 @@ class GraphSerializer(serializers.Serializer):
|
||||
'label': c.name,
|
||||
'title': f'{c.organization}\n{c.position}'.strip() or c.name,
|
||||
'group': c.organization or 'default',
|
||||
'life_sphere': c.life_sphere,
|
||||
'network_circle': c.network_circle,
|
||||
'importance': c.importance,
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
@@ -69,6 +76,8 @@ class GraphSerializer(serializers.Serializer):
|
||||
'to': r.target_id,
|
||||
'label': r.get_relation_type_display(),
|
||||
'title': r.description or r.get_relation_type_display(),
|
||||
'relation_type': r.relation_type,
|
||||
'interaction_intensity': r.interaction_intensity,
|
||||
}
|
||||
for r in relations
|
||||
]
|
||||
|
||||
@@ -9,6 +9,8 @@ router.register('relations', views.RelationViewSet)
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('graph/', views.graph_data),
|
||||
path('network-map-graph/', views.network_map_graph),
|
||||
path('relation-types/', views.relation_types),
|
||||
path('network-map-choices/', views.network_map_choices),
|
||||
path('import/', views.import_contacts),
|
||||
]
|
||||
|
||||
@@ -6,7 +6,14 @@ from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import api_view, action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Contact, Relation, RELATION_TYPES
|
||||
from .models import (
|
||||
Contact,
|
||||
Relation,
|
||||
RELATION_TYPES,
|
||||
LIFE_SPHERES,
|
||||
NETWORK_CIRCLES,
|
||||
INTERACTION_INTENSITY,
|
||||
)
|
||||
from .serializers import ContactSerializer, RelationSerializer, GraphSerializer
|
||||
|
||||
|
||||
@@ -37,6 +44,11 @@ def graph_data(request):
|
||||
'label': c.name,
|
||||
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
|
||||
'group': c.organization or 'default',
|
||||
'life_sphere': c.life_sphere,
|
||||
'network_circle': c.network_circle,
|
||||
'importance': c.importance,
|
||||
'map_angle': c.map_angle,
|
||||
'map_radius_ratio': c.map_radius_ratio,
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
@@ -49,18 +61,67 @@ def graph_data(request):
|
||||
'label': r.get_relation_type_display(),
|
||||
'title': r.description or r.get_relation_type_display(),
|
||||
'relation_type': r.relation_type,
|
||||
'interaction_intensity': r.interaction_intensity,
|
||||
}
|
||||
for r in relations
|
||||
]
|
||||
return Response({'nodes': nodes, 'edges': edges})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def network_map_graph(request):
|
||||
"""Граф только для карты сети: контакты с include_on_network_map и связи между ними."""
|
||||
contacts = list(
|
||||
Contact.objects.filter(include_on_network_map=True).order_by('name')
|
||||
)
|
||||
allowed_ids = {c.id for c in contacts}
|
||||
nodes = [
|
||||
{
|
||||
'id': c.id,
|
||||
'label': c.name,
|
||||
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
|
||||
'group': c.organization or 'default',
|
||||
'life_sphere': c.life_sphere,
|
||||
'network_circle': c.network_circle,
|
||||
'importance': c.importance,
|
||||
'map_angle': c.map_angle,
|
||||
'map_radius_ratio': c.map_radius_ratio,
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
relations = Relation.objects.select_related('source', 'target').all()
|
||||
edges = [
|
||||
{
|
||||
'id': r.id,
|
||||
'from': r.source_id,
|
||||
'to': r.target_id,
|
||||
'label': r.get_relation_type_display(),
|
||||
'title': r.description or r.get_relation_type_display(),
|
||||
'relation_type': r.relation_type,
|
||||
'interaction_intensity': r.interaction_intensity,
|
||||
}
|
||||
for r in relations
|
||||
if r.source_id in allowed_ids and r.target_id in allowed_ids
|
||||
]
|
||||
return Response({'nodes': nodes, 'edges': edges})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def relation_types(request):
|
||||
"""Список допустимых типов связей."""
|
||||
return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES])
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def network_map_choices(request):
|
||||
"""Подписи для карты сети: сферы, круги, интенсивность связей."""
|
||||
return Response({
|
||||
'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES],
|
||||
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
|
||||
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
|
||||
})
|
||||
|
||||
|
||||
def _monica_contact_fields(contact_data):
|
||||
"""Из вложенного data контакта Monica (экспорт account.data) извлекает телефон, email, заметки."""
|
||||
phone = ''
|
||||
|
||||
Binary file not shown.
Generated
+2412
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
@@ -16,7 +18,10 @@
|
||||
"vis-data": "^7.1.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"vite": "^5.1.0"
|
||||
"jsdom": "^25.0.1",
|
||||
"vite": "^5.1.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
+101
-9
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<aside class="sidebar sidebar-collapsible">
|
||||
<div class="sidebar-logo">
|
||||
<h1>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style="display:inline;vertical-align:-3px;margin-right:6px;">
|
||||
@@ -12,9 +12,9 @@
|
||||
<line x1="8" y1="12" x2="16" y2="17" stroke="#5b8dee" stroke-width="1.5"/>
|
||||
<line x1="16" y1="7" x2="16" y2="17" stroke="#7b82a6" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
</svg>
|
||||
Social Graph
|
||||
<span class="sidebar-title">Social Graph</span>
|
||||
</h1>
|
||||
<span>Построитель социального графа</span>
|
||||
<span class="sidebar-subtitle">Построитель социального графа</span>
|
||||
</div>
|
||||
<nav>
|
||||
<RouterLink to="/graph" class="nav-link" active-class="active">
|
||||
@@ -22,7 +22,15 @@
|
||||
<circle cx="5" cy="12" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="19" cy="19" r="2"/>
|
||||
<line x1="7" y1="11.5" x2="17" y2="6.5"/><line x1="7" y1="12.5" x2="17" y2="17.5"/>
|
||||
</svg>
|
||||
Граф
|
||||
<span class="nav-label">Граф</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/map" class="nav-link" active-class="active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<circle cx="12" cy="12" r="5" opacity="0.6"/>
|
||||
<circle cx="12" cy="12" r="2" opacity="0.8"/>
|
||||
</svg>
|
||||
<span class="nav-label">Карта сети</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/contacts" class="nav-link" active-class="active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -30,7 +38,7 @@
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
Контакты
|
||||
<span class="nav-label">Контакты</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/import" class="nav-link" active-class="active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -38,12 +46,28 @@
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Импорт
|
||||
<span class="nav-label">Импорт</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<button
|
||||
class="nav-link nav-theme-btn"
|
||||
type="button"
|
||||
@click="toggleTheme"
|
||||
:title="isLightTheme ? 'Переключить на темную тему' : 'Переключить на светлую тему'"
|
||||
>
|
||||
<svg v-if="isLightTheme" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
<span class="nav-label">{{ isLightTheme ? 'Темная тема' : 'Светлая тема' }}</span>
|
||||
</button>
|
||||
|
||||
<div class="sidebar-stats">
|
||||
<div class="stat">Контактов: <strong>{{ store.totalContacts }}</strong></div>
|
||||
<div class="stat">Связей: <strong>{{ store.totalRelations }}</strong></div>
|
||||
<div class="stat"><span class="nav-label">Контактов: </span><strong>{{ store.totalContacts }}</strong></div>
|
||||
<div class="stat"><span class="nav-label">Связей: </span><strong>{{ store.totalRelations }}</strong></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -55,13 +79,81 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { useContactsStore } from './stores/contacts'
|
||||
|
||||
const store = useContactsStore()
|
||||
const THEME_KEY = 'ui-theme'
|
||||
const currentTheme = ref('dark')
|
||||
|
||||
const isLightTheme = computed(() => currentTheme.value === 'light')
|
||||
|
||||
function applyTheme(theme) {
|
||||
currentTheme.value = theme
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem(THEME_KEY, theme)
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
applyTheme(isLightTheme.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const savedTheme = localStorage.getItem(THEME_KEY)
|
||||
applyTheme(savedTheme === 'light' ? 'light' : 'dark')
|
||||
await store.fetchContacts()
|
||||
await store.fetchRelations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-collapsible {
|
||||
width: 60px;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-collapsible:hover {
|
||||
width: 220px;
|
||||
}
|
||||
.sidebar-collapsible .sidebar-logo {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
.sidebar-collapsible .sidebar-title,
|
||||
.sidebar-collapsible .sidebar-subtitle,
|
||||
.sidebar-collapsible .nav-label {
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-collapsible:hover .sidebar-title,
|
||||
.sidebar-collapsible:hover .sidebar-subtitle,
|
||||
.sidebar-collapsible:hover .nav-label {
|
||||
display: inline;
|
||||
}
|
||||
.sidebar-collapsible .nav-link {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.sidebar-collapsible .nav-theme-btn {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.sidebar-collapsible:hover .nav-link {
|
||||
justify-content: flex-start;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.sidebar-collapsible .sidebar-stats {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.sidebar-collapsible:hover .sidebar-stats {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import axios from 'axios'
|
||||
import { normalizeApiError } from './lib/api/errors'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 12000,
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => Promise.reject(normalizeApiError(error))
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--surface-alt: #22263a;
|
||||
@@ -21,6 +22,24 @@
|
||||
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0f3fa;
|
||||
--border: #d7deef;
|
||||
--accent: #2f6feb;
|
||||
--accent-hover: #1f5bd2;
|
||||
--accent-dim: rgba(47, 111, 235, 0.12);
|
||||
--text: #1a2238;
|
||||
--text-muted: #5e6b8a;
|
||||
--text-dim: #8b96b0;
|
||||
--red: #cc3d52;
|
||||
--green: #1d9c77;
|
||||
--orange: #cb7a2d;
|
||||
--shadow: 0 10px 30px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; }
|
||||
#app { display: flex; flex-direction: column; height: 100%; }
|
||||
|
||||
@@ -79,6 +98,27 @@ a:hover { color: var(--accent-hover); }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header__title h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.page-header h2 { font-size: 18px; font-weight: 600; }
|
||||
.page-content { padding: 20px 28px; flex: 1; }
|
||||
@@ -136,6 +176,12 @@ textarea.form-control { resize: vertical; min-height: 80px; }
|
||||
.table th { color: var(--text-muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.table tr:hover td { background: var(--surface-alt); cursor: pointer; }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ===== Badge ===== */
|
||||
.badge {
|
||||
@@ -155,8 +201,10 @@ textarea.form-control { resize: vertical; min-height: 80px; }
|
||||
/* ===== Modal ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
@@ -165,7 +213,10 @@ textarea.form-control { resize: vertical; min-height: 80px; }
|
||||
padding: 28px;
|
||||
width: 460px;
|
||||
max-width: 95vw;
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
margin: auto 0;
|
||||
}
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.modal-header h3 { font-size: 16px; font-weight: 600; }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContactForm from './ContactForm.vue'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
default: {
|
||||
get: vi.fn(async () => ({
|
||||
data: {
|
||||
life_spheres: [{ value: 'work', label: 'Работа' }],
|
||||
network_circles: [{ value: 'support', label: 'Круг поддержки' }],
|
||||
},
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ContactForm', () => {
|
||||
it('emits submit with normalized payload', async () => {
|
||||
const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } })
|
||||
await wrapper.get('form').trigger('submit.prevent')
|
||||
const payload = wrapper.emitted('submit')[0][0]
|
||||
expect(payload.name).toBe('Иван')
|
||||
expect(payload.importance).toBeDefined()
|
||||
expect(payload.include_on_network_map).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,35 @@
|
||||
<input v-model="form.position" class="form-control" placeholder="Директор" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-form-map-fields">
|
||||
<div class="form-group">
|
||||
<label>Сфера жизни</label>
|
||||
<SearchableSelect
|
||||
v-model="form.life_sphere"
|
||||
:options="lifeSpheres"
|
||||
placeholder="Сфера жизни..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Круг сети</label>
|
||||
<SearchableSelect
|
||||
v-model="form.network_circle"
|
||||
:options="networkCircles"
|
||||
placeholder="Круг сети..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Важность (1–5)</label>
|
||||
<input v-model.number="form.importance" type="number" min="1" max="5" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group checkbox-row">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="form.include_on_network_map" type="checkbox" />
|
||||
Показывать на карте сети
|
||||
</label>
|
||||
<p class="checkbox-hint">На странице «Граф» контакт виден всегда; на «Карте сети» — только с этой отметкой.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Заметки</label>
|
||||
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
||||
@@ -36,11 +65,30 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue'
|
||||
import { reactive, watch, ref, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
|
||||
const FALLBACK_SPHERES = [
|
||||
{ value: 'work', label: 'Работа' },
|
||||
{ value: 'study', label: 'Учёба' },
|
||||
{ value: 'hobby', label: 'Хобби' },
|
||||
{ value: 'family', label: 'Семья' },
|
||||
{ value: 'health', label: 'Здоровье' },
|
||||
{ value: 'other', label: 'Другое' },
|
||||
]
|
||||
const FALLBACK_CIRCLES = [
|
||||
{ value: 'support', label: 'Круг поддержки' },
|
||||
{ value: 'productivity', label: 'Круг продуктивности' },
|
||||
{ value: 'development', label: 'Круг развития' },
|
||||
]
|
||||
|
||||
const props = defineProps({ initial: { type: Object, default: () => ({}) } })
|
||||
const emit = defineEmits(['submit', 'cancel'])
|
||||
|
||||
const lifeSpheres = ref([...FALLBACK_SPHERES])
|
||||
const networkCircles = ref([...FALLBACK_CIRCLES])
|
||||
|
||||
const form = reactive({
|
||||
name: props.initial.name || '',
|
||||
email: props.initial.email || '',
|
||||
@@ -48,13 +96,78 @@ const form = reactive({
|
||||
organization: props.initial.organization || '',
|
||||
position: props.initial.position || '',
|
||||
notes: props.initial.notes || '',
|
||||
life_sphere: props.initial.life_sphere || 'other',
|
||||
network_circle: props.initial.network_circle || 'productivity',
|
||||
importance: props.initial.importance ?? 3,
|
||||
include_on_network_map: Boolean(props.initial.include_on_network_map),
|
||||
})
|
||||
|
||||
watch(() => props.initial, (v) => {
|
||||
if (v) Object.assign(form, v)
|
||||
if (!v || !Object.keys(v).length) {
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
organization: '',
|
||||
position: '',
|
||||
notes: '',
|
||||
life_sphere: 'other',
|
||||
network_circle: 'productivity',
|
||||
importance: 3,
|
||||
include_on_network_map: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
Object.assign(form, {
|
||||
name: v.name || '',
|
||||
email: v.email || '',
|
||||
phone: v.phone || '',
|
||||
organization: v.organization || '',
|
||||
position: v.position || '',
|
||||
notes: v.notes || '',
|
||||
life_sphere: v.life_sphere || 'other',
|
||||
network_circle: v.network_circle || 'productivity',
|
||||
importance: v.importance ?? 3,
|
||||
include_on_network_map: Boolean(v.include_on_network_map),
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/network-map-choices/')
|
||||
if (data.life_spheres?.length) lifeSpheres.value = data.life_spheres
|
||||
if (data.network_circles?.length) networkCircles.value = data.network_circles
|
||||
} catch {
|
||||
/* оставляем FALLBACK_* */
|
||||
}
|
||||
})
|
||||
|
||||
function onSubmit() {
|
||||
emit('submit', { ...form })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contact-form-map-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="graph-view-header">
|
||||
<h2>{{ title }}</h2>
|
||||
<div class="flex gap-2">
|
||||
<button 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">
|
||||
<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"/>
|
||||
</svg>
|
||||
{{ resetLabel }}
|
||||
</button>
|
||||
<button v-if="showPhysicsToggle" class="btn btn-secondary btn-sm" @click="$emit('toggle-physics')">
|
||||
{{ physicsEnabled ? physicsOnLabel : physicsOffLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: 'Граф связей' },
|
||||
resetLabel: { type: String, default: 'Сбросить вид' },
|
||||
showPhysicsToggle: { type: Boolean, default: false },
|
||||
physicsEnabled: { type: Boolean, default: true },
|
||||
physicsOnLabel: { type: String, default: 'Заморозить' },
|
||||
physicsOffLabel: { type: String, default: 'Оживить' },
|
||||
})
|
||||
defineEmits(['reset', 'toggle-physics'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-view-header {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 28px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.graph-view-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="network-map-legend card">
|
||||
<div class="legend-row">
|
||||
<span><strong class="legend-strong">━━</strong> интенсивные</span>
|
||||
<span><strong class="legend-strong">╌╌</strong> редкие</span>
|
||||
<span class="legend-hint">{{ hint }}</span>
|
||||
</div>
|
||||
<div class="legend-rings">
|
||||
<span class="legend-ring-inner">внутреннее кольцо — поддержка</span>
|
||||
<span class="legend-ring-mid">среднее — продуктивность</span>
|
||||
<span class="legend-ring-outer">внешнее — развитие</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
hint: {
|
||||
type: String,
|
||||
default: 'Имя — в подсказке при наведении; клик — карточка. Масштаб и панорама — мышью; точки закреплены в секторах.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-legend {
|
||||
margin: 0 28px 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.legend-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.legend-strong {
|
||||
color: var(--text);
|
||||
}
|
||||
.legend-hint {
|
||||
flex: 1 1 200px;
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.legend-rings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 18px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.legend-ring-inner::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: -1px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.legend-ring-mid::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: -2px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.legend-ring-outer::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: -3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="network-map-top-panel" :class="{ collapsed }">
|
||||
<button
|
||||
class="panel-toggle-btn"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="collapsed ? 'Развернуть панель' : 'Свернуть панель'"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline v-if="!collapsed" points="18 15 12 9 6 15" />
|
||||
<polyline v-else points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="network-map-header" v-show="!collapsed">
|
||||
<div>
|
||||
<h2>{{ title }}</h2>
|
||||
<p class="network-map-sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<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 class="network-map-toolbar" v-show="!collapsed">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed">
|
||||
<slot name="legend" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
collapsed: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Карта сети' },
|
||||
subtitle: {
|
||||
type: String,
|
||||
default:
|
||||
'Три круга — поддержка, продуктивность, развитие. Секторы — сферы жизни. Толстая линия — частые контакты, пунктир — редкие. Стрелка — от инициатора связи.',
|
||||
},
|
||||
})
|
||||
defineEmits(['toggle-collapse', 'fit'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-top-panel {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.network-map-top-panel.collapsed {
|
||||
height: 26px;
|
||||
}
|
||||
.panel-toggle-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
width: 24px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.network-map-header {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 28px 8px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.network-map-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.network-map-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
max-width: 640px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.network-map-toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28px 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="relation-filters">
|
||||
<button
|
||||
v-for="rt in relationTypes"
|
||||
:key="rt.value"
|
||||
class="btn btn-sm"
|
||||
:class="activeValues.includes(rt.value) ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="$emit('toggle', rt.value)"
|
||||
>
|
||||
{{ rt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
relationTypes: { type: Array, default: () => [] },
|
||||
activeValues: { type: Array, default: () => [] },
|
||||
})
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.relation-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
|
||||
const options = [
|
||||
{ value: 1, label: 'Иван Петров' },
|
||||
{ value: 2, label: 'Мария Сидорова' },
|
||||
]
|
||||
|
||||
describe('SearchableSelect', () => {
|
||||
it('filters options while typing', async () => {
|
||||
const wrapper = mount(SearchableSelect, {
|
||||
props: { modelValue: '', options, placeholder: 'Поиск' },
|
||||
})
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('мария')
|
||||
await input.trigger('focus')
|
||||
|
||||
const items = wrapper.findAll('.searchable-select__option')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].text()).toBe('Мария Сидорова')
|
||||
})
|
||||
|
||||
it('emits selected value on option click', async () => {
|
||||
const wrapper = mount(SearchableSelect, {
|
||||
props: { modelValue: '', options },
|
||||
})
|
||||
await wrapper.get('input').setValue('иван')
|
||||
await wrapper.get('input').trigger('focus')
|
||||
await wrapper.find('.searchable-select__option').trigger('mousedown')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
|
||||
})
|
||||
|
||||
it('shows label for current model value', async () => {
|
||||
const wrapper = mount(SearchableSelect, {
|
||||
props: { modelValue: 2, options },
|
||||
})
|
||||
expect(wrapper.get('input').element.value).toBe('Мария Сидорова')
|
||||
})
|
||||
|
||||
it('keeps input empty when no value is selected', async () => {
|
||||
const wrapper = mount(SearchableSelect, {
|
||||
props: { modelValue: '', options, placeholder: 'Введите имя' },
|
||||
})
|
||||
expect(wrapper.get('input').element.value).toBe('')
|
||||
expect(wrapper.get('input').attributes('placeholder')).toBe('Введите имя')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="searchable-select" ref="rootRef">
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
class="form-control searchable-select__input"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
aria-autocomplete="list"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<ul v-if="open && filteredOptions.length" class="searchable-select__list" role="listbox">
|
||||
<li
|
||||
v-for="(opt, index) in filteredOptions"
|
||||
:key="String(opt.value)"
|
||||
role="option"
|
||||
:aria-selected="isSelected(opt)"
|
||||
class="searchable-select__option"
|
||||
:class="{
|
||||
'is-active': index === highlightedIndex,
|
||||
'is-selected': isSelected(opt),
|
||||
}"
|
||||
@mousedown.prevent="selectOption(opt)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="open && query.trim() && !filteredOptions.length" class="searchable-select__empty">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: 'Начните вводить...' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const rootRef = ref(null)
|
||||
const inputRef = ref(null)
|
||||
const query = ref('')
|
||||
const open = ref(false)
|
||||
const highlightedIndex = ref(0)
|
||||
|
||||
const allOptions = computed(() =>
|
||||
props.options.map((o) => ({ value: o.value, label: o.label }))
|
||||
)
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase('ru')
|
||||
if (!q) return allOptions.value
|
||||
return allOptions.value.filter((o) =>
|
||||
o.label.toLocaleLowerCase('ru').includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
function valuesEqual(a, b) {
|
||||
if (a === '' || a === null || a === undefined) {
|
||||
return b === '' || b === null || b === undefined
|
||||
}
|
||||
return String(a) === String(b)
|
||||
}
|
||||
|
||||
function isEmptyValue(value) {
|
||||
return value === '' || value === null || value === undefined
|
||||
}
|
||||
|
||||
function labelForValue(value) {
|
||||
if (isEmptyValue(value)) return ''
|
||||
const opt = allOptions.value.find((o) => valuesEqual(o.value, value))
|
||||
return opt?.label ?? ''
|
||||
}
|
||||
|
||||
function isSelected(opt) {
|
||||
return valuesEqual(opt.value, props.modelValue)
|
||||
}
|
||||
|
||||
function syncQueryFromModel() {
|
||||
query.value = labelForValue(props.modelValue)
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
query.value = e.target.value
|
||||
open.value = true
|
||||
highlightedIndex.value = 0
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
open.value = true
|
||||
if (isEmptyValue(props.modelValue)) {
|
||||
query.value = ''
|
||||
highlightedIndex.value = 0
|
||||
return
|
||||
}
|
||||
query.value = labelForValue(props.modelValue)
|
||||
highlightedIndex.value = Math.max(
|
||||
0,
|
||||
filteredOptions.value.findIndex((o) => isSelected(o))
|
||||
)
|
||||
nextTick(() => inputRef.value?.select())
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
setTimeout(() => {
|
||||
open.value = false
|
||||
syncQueryFromModel()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function selectOption(opt) {
|
||||
emit('update:modelValue', opt.value)
|
||||
query.value = isEmptyValue(opt.value) ? '' : opt.label
|
||||
open.value = false
|
||||
highlightedIndex.value = 0
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!open.value && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
open.value = true
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
open.value = false
|
||||
syncQueryFromModel()
|
||||
inputRef.value?.blur()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!filteredOptions.value.length) return
|
||||
highlightedIndex.value = Math.min(
|
||||
highlightedIndex.value + 1,
|
||||
filteredOptions.value.length - 1
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!filteredOptions.value.length) return
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const opt = filteredOptions.value[highlightedIndex.value]
|
||||
if (opt) selectOption(opt)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (!open.value) syncQueryFromModel()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
() => {
|
||||
if (!open.value) syncQueryFromModel()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(filteredOptions, (list) => {
|
||||
if (highlightedIndex.value >= list.length) {
|
||||
highlightedIndex.value = Math.max(0, list.length - 1)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.searchable-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchable-select__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.searchable-select__list {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.searchable-select__option {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.searchable-select__option:hover,
|
||||
.searchable-select__option.is-active {
|
||||
background: var(--surface-alt);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.searchable-select__option.is-selected {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.searchable-select__empty {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
import api from '../api'
|
||||
|
||||
export async function fetchGraphBundle(graphEndpoint = '/graph/') {
|
||||
const [gRes, rtRes] = await Promise.all([
|
||||
api.get(graphEndpoint),
|
||||
api.get('/relation-types/'),
|
||||
])
|
||||
return {
|
||||
nodes: gRes.data.nodes || [],
|
||||
edges: gRes.data.edges || [],
|
||||
relationTypes: rtRes.data || [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMapChoices() {
|
||||
const { data } = await api.get('/network-map-choices/')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function normalizeApiError(error) {
|
||||
const status = error?.response?.status ?? null
|
||||
const data = error?.response?.data
|
||||
let message = 'Произошла ошибка запроса.'
|
||||
|
||||
if (typeof data === 'string' && data.trim()) {
|
||||
message = data
|
||||
} else if (typeof data?.error === 'string' && data.error.trim()) {
|
||||
message = data.error
|
||||
} else if (typeof error?.message === 'string' && error.message.trim()) {
|
||||
message = error.message
|
||||
}
|
||||
|
||||
const normalized = new Error(message)
|
||||
normalized.status = status
|
||||
normalized.data = data
|
||||
normalized.original = error
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { normalizeApiError } from './errors'
|
||||
|
||||
describe('normalizeApiError', () => {
|
||||
it('extracts API message from response payload', () => {
|
||||
const err = normalizeApiError({ response: { status: 400, data: { error: 'Bad request' } } })
|
||||
expect(err.message).toBe('Bad request')
|
||||
expect(err.status).toBe(400)
|
||||
})
|
||||
|
||||
it('falls back to generic message', () => {
|
||||
const err = normalizeApiError({})
|
||||
expect(err.message.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Loads all items from a DRF paginated list endpoint (page query param).
|
||||
* If the response is a plain array, returns it as-is.
|
||||
*/
|
||||
export async function fetchAllPages(requestPage) {
|
||||
const all = []
|
||||
let page = 1
|
||||
|
||||
while (true) {
|
||||
const { data } = await requestPage(page)
|
||||
if (Array.isArray(data)) return data
|
||||
|
||||
all.push(...(data.results ?? []))
|
||||
if (!data.next) break
|
||||
page += 1
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { fetchAllPages } from './pagination'
|
||||
|
||||
describe('fetchAllPages', () => {
|
||||
it('returns a plain array response unchanged', async () => {
|
||||
const requestPage = vi.fn().mockResolvedValue({ data: [{ id: 1 }] })
|
||||
await expect(fetchAllPages(requestPage)).resolves.toEqual([{ id: 1 }])
|
||||
expect(requestPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('concatenates all paginated pages', async () => {
|
||||
const requestPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { results: [{ id: 1 }], next: 'http://example/api/?page=2' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { results: [{ id: 2 }], next: null },
|
||||
})
|
||||
|
||||
await expect(fetchAllPages(requestPage)).resolves.toEqual([{ id: 1 }, { id: 2 }])
|
||||
expect(requestPage).toHaveBeenCalledTimes(2)
|
||||
expect(requestPage).toHaveBeenNthCalledWith(1, 1)
|
||||
expect(requestPage).toHaveBeenNthCalledWith(2, 2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export const RELATION_COLORS = {
|
||||
colleague: { color: '#4facfe', highlight: '#7ac8ff' },
|
||||
friend: { color: '#4ecca3', highlight: '#7edfc0' },
|
||||
family: { color: '#f4a261', highlight: '#f7bb8a' },
|
||||
business: { color: '#5b8dee', highlight: '#7aa5f5' },
|
||||
acquaintance: { color: '#7b82a6', highlight: '#9ba3c5' },
|
||||
other: { color: '#555d7a', highlight: '#7b82a6' },
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export const SPHERE_ORDER = ['work', 'study', 'hobby', 'family', 'health', 'other']
|
||||
|
||||
export function ringRadius(networkCircle, layout) {
|
||||
if (networkCircle === 'support') return layout.rInner
|
||||
if (networkCircle === 'productivity') return layout.rMid
|
||||
return layout.rOuter
|
||||
}
|
||||
|
||||
export function ringByRadius(r, L) {
|
||||
const t1 = (L.rInner + L.rMid) / 2
|
||||
const t2 = (L.rMid + L.rOuter) / 2
|
||||
if (r <= t1) return 'support'
|
||||
if (r <= t2) return 'productivity'
|
||||
return 'development'
|
||||
}
|
||||
|
||||
export function sphereByAngle(angle) {
|
||||
const full = 2 * Math.PI
|
||||
const norm = ((angle + Math.PI / 2) % full + full) % full
|
||||
const sectorW = full / SPHERE_ORDER.length
|
||||
const idx = Math.floor(norm / sectorW)
|
||||
return SPHERE_ORDER[Math.max(0, Math.min(SPHERE_ORDER.length - 1, idx))]
|
||||
}
|
||||
|
||||
export function normalizedSphere(n) {
|
||||
const s = n.life_sphere
|
||||
return SPHERE_ORDER.includes(s) ? s : 'other'
|
||||
}
|
||||
|
||||
export function normalizedCircle(n) {
|
||||
const c = n.network_circle
|
||||
return c === 'support' || c === 'productivity' || c === 'development' ? c : 'productivity'
|
||||
}
|
||||
|
||||
export function computePolarPositions(rawNodes, L) {
|
||||
const groups = new Map()
|
||||
for (const n of rawNodes) {
|
||||
const key = `${normalizedSphere(n)}|${normalizedCircle(n)}`
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key).push(n)
|
||||
}
|
||||
const posById = new Map()
|
||||
const nSectors = SPHERE_ORDER.length
|
||||
for (const [, group] of groups) {
|
||||
group.sort((a, b) => Number(a.id) - Number(b.id))
|
||||
const sample = group[0]
|
||||
const sphere = normalizedSphere(sample)
|
||||
const circle = normalizedCircle(sample)
|
||||
const idx = SPHERE_ORDER.indexOf(sphere)
|
||||
const sectorStart = (idx / nSectors) * 2 * Math.PI - Math.PI / 2
|
||||
const sectorW = (2 * Math.PI) / nSectors
|
||||
const pad = sectorW * 0.07
|
||||
const usable = Math.max(sectorW - 2 * pad, sectorW * 0.2)
|
||||
const rBase = ringRadius(circle, L)
|
||||
const k = group.length
|
||||
group.forEach((n, i) => {
|
||||
const angle = k === 1
|
||||
? sectorStart + sectorW / 2
|
||||
: sectorStart + pad + ((i + 0.5) / k) * usable
|
||||
posById.set(n.id, { x: rBase * Math.cos(angle), y: rBase * Math.sin(angle) })
|
||||
})
|
||||
}
|
||||
return posById
|
||||
}
|
||||
|
||||
export function nodeXY(n, layout, posById) {
|
||||
const ratio = Number(n.map_radius_ratio)
|
||||
const storedAngle = Number(n.map_angle)
|
||||
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
|
||||
const safeRatio = Math.max(0, Math.min(1, ratio))
|
||||
const r = safeRatio * layout.rOuter
|
||||
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
|
||||
}
|
||||
const p = posById.get(n.id)
|
||||
if (p) return p
|
||||
const idx = SPHERE_ORDER.indexOf(normalizedSphere(n))
|
||||
const sectorStart = (Math.max(0, idx) / SPHERE_ORDER.length) * 2 * Math.PI - Math.PI / 2
|
||||
const r = ringRadius(normalizedCircle(n), layout)
|
||||
const angle = sectorStart + (2 * Math.PI) / SPHERE_ORDER.length / 2
|
||||
return { x: r * Math.cos(angle), y: r * Math.sin(angle) }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sphereByAngle, ringByRadius, nodeXY } from './positioning'
|
||||
|
||||
describe('map positioning', () => {
|
||||
it('maps angle to sphere deterministically', () => {
|
||||
expect(sphereByAngle(-Math.PI / 2)).toBe('work')
|
||||
expect(sphereByAngle(0)).toBe('study')
|
||||
})
|
||||
|
||||
it('maps radius to ring', () => {
|
||||
const L = { rInner: 10, rMid: 20, rOuter: 30 }
|
||||
expect(ringByRadius(5, L)).toBe('support')
|
||||
expect(ringByRadius(16, L)).toBe('productivity')
|
||||
expect(ringByRadius(28, L)).toBe('development')
|
||||
})
|
||||
|
||||
it('prefers persisted coordinates', () => {
|
||||
const L = { rInner: 10, rMid: 20, rOuter: 100 }
|
||||
const p = nodeXY({ map_radius_ratio: 0.5, map_angle: 0 }, L, new Map())
|
||||
expect(p.x).toBeCloseTo(50)
|
||||
expect(p.y).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,11 @@ const routes = [
|
||||
name: 'Graph',
|
||||
component: () => import('../views/GraphView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/map',
|
||||
name: 'NetworkMap',
|
||||
component: () => import('../views/NetworkMapView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/contacts',
|
||||
name: 'Contacts',
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import api from '../api'
|
||||
import { fetchAllPages } from '../lib/api/pagination'
|
||||
|
||||
export const useContactsStore = defineStore('contacts', {
|
||||
state: () => ({
|
||||
contacts: [],
|
||||
relations: [],
|
||||
relationTypes: [],
|
||||
mapChoices: null,
|
||||
loading: false,
|
||||
contactsLoading: false,
|
||||
relationsLoading: false,
|
||||
mapLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
@@ -16,52 +22,116 @@ export const useContactsStore = defineStore('contacts', {
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchContacts(search = '') {
|
||||
this.loading = true
|
||||
setError(error) {
|
||||
this.error = error?.message || String(error)
|
||||
},
|
||||
|
||||
async withLoading(flagName, fn) {
|
||||
this[flagName] = true
|
||||
this.loading = this.contactsLoading || this.relationsLoading || this.mapLoading
|
||||
this.error = null
|
||||
try {
|
||||
const params = search ? { search } : {}
|
||||
const { data } = await api.get('/contacts/', { params })
|
||||
this.contacts = data.results ?? data
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
this.error = e.message
|
||||
this.setError(e)
|
||||
throw e
|
||||
} finally {
|
||||
this.loading = false
|
||||
this[flagName] = false
|
||||
this.loading = this.contactsLoading || this.relationsLoading || this.mapLoading
|
||||
}
|
||||
},
|
||||
|
||||
async fetchContacts(search = '') {
|
||||
return this.withLoading('contactsLoading', async () => {
|
||||
const params = search ? { search } : {}
|
||||
this.contacts = await fetchAllPages((page) =>
|
||||
api.get('/contacts/', { params: { ...params, page } })
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
async fetchContactById(id) {
|
||||
return this.withLoading('contactsLoading', async () => {
|
||||
const { data } = await api.get(`/contacts/${id}/`)
|
||||
const idx = this.contacts.findIndex((c) => c.id === id)
|
||||
if (idx !== -1) this.contacts[idx] = data
|
||||
return data
|
||||
})
|
||||
},
|
||||
|
||||
async fetchRelations() {
|
||||
const { data } = await api.get('/relations/')
|
||||
this.relations = data.results ?? data
|
||||
return this.withLoading('relationsLoading', async () => {
|
||||
this.relations = await fetchAllPages((page) =>
|
||||
api.get('/relations/', { params: { page } })
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
async fetchRelationTypes() {
|
||||
return this.withLoading('mapLoading', async () => {
|
||||
const { data } = await api.get('/relation-types/')
|
||||
this.relationTypes = data
|
||||
return data
|
||||
})
|
||||
},
|
||||
|
||||
async fetchNetworkMapChoices() {
|
||||
return this.withLoading('mapLoading', async () => {
|
||||
const { data } = await api.get('/network-map-choices/')
|
||||
this.mapChoices = data
|
||||
return data
|
||||
})
|
||||
},
|
||||
|
||||
async createContact(payload) {
|
||||
return this.withLoading('contactsLoading', async () => {
|
||||
const { data } = await api.post('/contacts/', payload)
|
||||
this.contacts.push(data)
|
||||
return data
|
||||
})
|
||||
},
|
||||
|
||||
async updateContact(id, payload) {
|
||||
return this.withLoading('contactsLoading', async () => {
|
||||
const { data } = await api.patch(`/contacts/${id}/`, payload)
|
||||
const idx = this.contacts.findIndex((c) => c.id === id)
|
||||
if (idx !== -1) this.contacts[idx] = data
|
||||
return data
|
||||
})
|
||||
},
|
||||
|
||||
async deleteContact(id) {
|
||||
return this.withLoading('contactsLoading', async () => {
|
||||
await api.delete(`/contacts/${id}/`)
|
||||
this.contacts = this.contacts.filter((c) => c.id !== id)
|
||||
})
|
||||
},
|
||||
|
||||
async createRelation(payload) {
|
||||
return this.withLoading('relationsLoading', async () => {
|
||||
const { data } = await api.post('/relations/', payload)
|
||||
this.relations.push(data)
|
||||
return data
|
||||
})
|
||||
},
|
||||
|
||||
async deleteRelation(id) {
|
||||
return this.withLoading('relationsLoading', async () => {
|
||||
await api.delete(`/relations/${id}/`)
|
||||
this.relations = this.relations.filter((r) => r.id !== id)
|
||||
})
|
||||
},
|
||||
|
||||
async importContacts(file) {
|
||||
return this.withLoading('mapLoading', async () => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const { data } = await api.post('/import/', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
await this.fetchContacts()
|
||||
return data
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="page-header__title">
|
||||
<button class="btn btn-secondary btn-sm" @click="$router.back()">← Назад</button>
|
||||
<h2>{{ contact?.name || 'Загрузка...' }}</h2>
|
||||
</div>
|
||||
<button v-if="contact" class="btn btn-primary btn-sm" @click="editing = true">Редактировать</button>
|
||||
<div v-if="contact" class="page-header__actions">
|
||||
<button class="btn btn-primary btn-sm" @click="editing = true">Редактировать</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content" v-if="contact">
|
||||
@@ -33,6 +35,17 @@
|
||||
<label>Заметки</label>
|
||||
<div style="white-space:pre-wrap;">{{ contact.notes || '—' }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Сфера жизни / круг / важность</label>
|
||||
<div>
|
||||
{{ sphereLabel(contact.life_sphere) }} · {{ circleLabel(contact.network_circle) }}
|
||||
· важность {{ contact.importance ?? '—' }}/5
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Карта сети</label>
|
||||
<div>{{ contact.include_on_network_map ? 'Показывается на карте' : 'Не на карте (только в общем графе)' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Relations card -->
|
||||
@@ -54,6 +67,7 @@
|
||||
<div>
|
||||
<span style="font-weight:500;">{{ rel.source === contact.id ? rel.target_name : rel.source_name }}</span>
|
||||
<span :class="`badge badge-${rel.relation_type}`" style="margin-left:8px;">{{ relLabel(rel.relation_type) }}</span>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">{{ intensityLabel(rel.interaction_intensity) }}</span>
|
||||
<div class="text-muted mt-1">{{ rel.description }}</div>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm" @click="removeRelation(rel.id)">✕</button>
|
||||
@@ -84,21 +98,35 @@
|
||||
<div v-if="relError" class="alert alert-error">{{ relError }}</div>
|
||||
<div class="form-group">
|
||||
<label>С кем связать</label>
|
||||
<select v-model="newRel.targetId" class="form-control">
|
||||
<option value="">— Выберите контакт —</option>
|
||||
<option v-for="c in otherContacts" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
<SearchableSelect
|
||||
v-model="newRel.targetId"
|
||||
:options="contactSelectOptions"
|
||||
placeholder="Введите имя контакта..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Тип связи</label>
|
||||
<select v-model="newRel.type" class="form-control">
|
||||
<option v-for="rt in relationTypes" :key="rt.value" :value="rt.value">{{ rt.label }}</option>
|
||||
</select>
|
||||
<SearchableSelect
|
||||
v-model="newRel.type"
|
||||
:options="relationTypes"
|
||||
placeholder="Тип связи..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<SearchableSelect
|
||||
v-model="newRel.interaction_intensity"
|
||||
:options="interactionIntensities"
|
||||
placeholder="Интенсивность..."
|
||||
/>
|
||||
</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>
|
||||
@@ -111,9 +139,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api from '../api'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import ContactForm from '../components/ContactForm.vue'
|
||||
import SearchableSelect from '../components/SearchableSelect.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useContactsStore()
|
||||
@@ -121,8 +149,16 @@ const contact = ref(null)
|
||||
const editing = ref(false)
|
||||
const showAddRelation = ref(false)
|
||||
const relationTypes = ref([])
|
||||
const lifeSpheres = ref([])
|
||||
const networkCircles = ref([])
|
||||
const interactionIntensities = ref([])
|
||||
const relError = ref('')
|
||||
const newRel = ref({ targetId: '', type: 'acquaintance', description: '' })
|
||||
const newRel = ref({
|
||||
targetId: '',
|
||||
type: 'acquaintance',
|
||||
description: '',
|
||||
interaction_intensity: 'intense',
|
||||
})
|
||||
|
||||
const contactId = computed(() => Number(route.params.id))
|
||||
|
||||
@@ -133,16 +169,34 @@ const contactRelations = computed(() =>
|
||||
)
|
||||
|
||||
const otherContacts = computed(() =>
|
||||
store.contacts.filter((c) => c.id !== contactId.value)
|
||||
store.contacts
|
||||
.filter((c) => c.id !== 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 }))
|
||||
)
|
||||
|
||||
function relLabel(type) {
|
||||
return relationTypes.value.find((r) => r.value === type)?.label || type
|
||||
}
|
||||
|
||||
function sphereLabel(v) {
|
||||
return lifeSpheres.value.find((x) => x.value === v)?.label || v || '—'
|
||||
}
|
||||
|
||||
function circleLabel(v) {
|
||||
return networkCircles.value.find((x) => x.value === v)?.label || v || '—'
|
||||
}
|
||||
|
||||
function intensityLabel(v) {
|
||||
return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
|
||||
}
|
||||
|
||||
async function loadContact() {
|
||||
const { data } = await api.get(`/contacts/${contactId.value}/`)
|
||||
contact.value = data
|
||||
contact.value = await store.fetchContactById(contactId.value)
|
||||
}
|
||||
|
||||
async function onUpdate(data) {
|
||||
@@ -159,9 +213,15 @@ async function addRelation() {
|
||||
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: '' }
|
||||
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)
|
||||
@@ -175,7 +235,13 @@ async function removeRelation(id) {
|
||||
onMounted(async () => {
|
||||
await loadContact()
|
||||
await Promise.all([store.fetchContacts(), store.fetchRelations()])
|
||||
const { data } = await api.get('/relation-types/')
|
||||
relationTypes.value = data
|
||||
const [rt, mapChoices] = await Promise.all([
|
||||
store.fetchRelationTypes(),
|
||||
store.fetchNetworkMapChoices(),
|
||||
])
|
||||
relationTypes.value = rt
|
||||
lifeSpheres.value = mapChoices.life_spheres
|
||||
networkCircles.value = mapChoices.network_circles
|
||||
interactionIntensities.value = mapChoices.interaction_intensities
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<th>Организация</th>
|
||||
<th>Email</th>
|
||||
<th>Связей</th>
|
||||
<th style="width:80px;"></th>
|
||||
<th style="width:180px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -54,7 +54,8 @@
|
||||
<td>
|
||||
<span class="badge badge-colleague">{{ c.relations_count }}</span>
|
||||
</td>
|
||||
<td @click.stop>
|
||||
<td @click.stop class="table-actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openEdit(c)">Редактировать</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(c)">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -74,6 +75,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<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" @submit="onUpdate" @cancel="editTarget = null" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirm -->
|
||||
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
|
||||
<div class="modal">
|
||||
@@ -100,6 +112,7 @@ const store = useContactsStore()
|
||||
const router = useRouter()
|
||||
const search = ref('')
|
||||
const showCreate = ref(false)
|
||||
const editTarget = ref(null)
|
||||
const deleteTarget = ref(null)
|
||||
|
||||
let searchTimer = null
|
||||
@@ -115,6 +128,15 @@ async function onCreate(data) {
|
||||
showCreate.value = false
|
||||
}
|
||||
|
||||
function openEdit(c) {
|
||||
editTarget.value = { ...c }
|
||||
}
|
||||
|
||||
async function onUpdate(data) {
|
||||
await store.updateContact(editTarget.value.id, data)
|
||||
editTarget.value = null
|
||||
}
|
||||
|
||||
function confirmDelete(c) { deleteTarget.value = c }
|
||||
|
||||
async function doDelete() {
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
<template>
|
||||
<div class="graph-view">
|
||||
<div class="graph-view-header">
|
||||
<h2>Граф связей</h2>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" @click="resetView">
|
||||
<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 3v5h5"/>
|
||||
</svg>
|
||||
Сбросить вид
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="togglePhysics">
|
||||
{{ physicsEnabled ? 'Заморозить' : 'Оживить' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<GraphHeaderPanel
|
||||
title="Граф связей"
|
||||
:show-physics-toggle="true"
|
||||
:physics-enabled="physicsEnabled"
|
||||
@reset="resetView"
|
||||
@toggle-physics="togglePhysics"
|
||||
/>
|
||||
|
||||
<div class="graph-view-toolbar">
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;">
|
||||
<button
|
||||
v-for="rt in allRelationTypes"
|
||||
:key="rt.value"
|
||||
class="btn btn-sm"
|
||||
:class="activeFilters.includes(rt.value) ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="toggleFilter(rt.value)"
|
||||
>
|
||||
{{ rt.label }}
|
||||
</button>
|
||||
</div>
|
||||
<RelationTypeFilters
|
||||
:relation-types="allRelationTypes"
|
||||
:active-values="activeFilters"
|
||||
@toggle="toggleFilter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="graph-area">
|
||||
@@ -82,8 +68,11 @@
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import api from '../api'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||
import { fetchGraphBundle } from '../composables/useGraphData'
|
||||
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
|
||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||
|
||||
const store = useContactsStore()
|
||||
const graphContainer = ref(null)
|
||||
@@ -98,6 +87,7 @@ let initRetryCount = 0
|
||||
const INIT_RETRY_MAX = 40
|
||||
let initRetryTimer = null
|
||||
let resizeObserver = null
|
||||
let themeObserver = null
|
||||
|
||||
const nodes = ref([])
|
||||
const edges = ref([])
|
||||
@@ -108,26 +98,30 @@ const selectedContact = computed(() =>
|
||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||
)
|
||||
|
||||
const RELATION_COLORS = {
|
||||
colleague: { color: '#4facfe', highlight: '#7ac8ff' },
|
||||
friend: { color: '#4ecca3', highlight: '#7edfc0' },
|
||||
family: { color: '#f4a261', highlight: '#f7bb8a' },
|
||||
business: { color: '#5b8dee', highlight: '#7aa5f5' },
|
||||
acquaintance: { color: '#7b82a6', highlight: '#9ba3c5' },
|
||||
other: { color: '#555d7a', highlight: '#7b82a6' },
|
||||
function cssVar(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
return value || fallback
|
||||
}
|
||||
|
||||
function graphPalette() {
|
||||
return {
|
||||
nodeBackground: cssVar('--surface-alt', '#22263a'),
|
||||
nodeBorder: cssVar('--accent', '#5b8dee'),
|
||||
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
|
||||
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
|
||||
nodeFont: cssVar('--text', '#e2e6f3'),
|
||||
edgeFont: cssVar('--text-muted', '#7b82a6'),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [gRes, rtRes] = await Promise.all([
|
||||
api.get('/graph/'),
|
||||
api.get('/relation-types/'),
|
||||
])
|
||||
nodes.value = gRes.data.nodes
|
||||
edges.value = gRes.data.edges
|
||||
allRelationTypes.value = rtRes.data
|
||||
activeFilters.value = rtRes.data.map((r) => r.value)
|
||||
const bundle = await fetchGraphBundle('/graph/')
|
||||
nodes.value = bundle.nodes
|
||||
edges.value = bundle.edges
|
||||
allRelationTypes.value = bundle.relationTypes
|
||||
activeFilters.value = bundle.relationTypes.map((r) => r.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -171,13 +165,18 @@ function initNetwork() {
|
||||
initRetryTimer = null
|
||||
}
|
||||
|
||||
const palette = graphPalette()
|
||||
const nodeList = nodes.value.map((n) => ({
|
||||
id: String(n.id),
|
||||
label: n.label || String(n.id),
|
||||
title: n.title,
|
||||
...(n.group != null && { group: n.group }),
|
||||
color: { background: '#1a1d27', border: '#5b8dee', highlight: { background: '#22263a', border: '#7aa5f5' } },
|
||||
font: { color: '#e2e6f3', size: 13 },
|
||||
color: {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeBorder,
|
||||
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
|
||||
},
|
||||
font: { color: palette.nodeFont, size: 13 },
|
||||
shape: 'dot',
|
||||
size: 14,
|
||||
}))
|
||||
@@ -189,7 +188,7 @@ function initNetwork() {
|
||||
title: e.title,
|
||||
relation_type: e.relation_type,
|
||||
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
|
||||
font: { color: '#7b82a6', size: 10, align: 'middle' },
|
||||
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'curvedCW', roundness: 0.1 },
|
||||
}))
|
||||
@@ -251,12 +250,13 @@ function toggleFilter(type) {
|
||||
activeFilters.value.push(type)
|
||||
}
|
||||
if (edgesDS) {
|
||||
const palette = graphPalette()
|
||||
edgesDS.clear()
|
||||
edgesDS.add(
|
||||
filteredEdges().map((e) => ({
|
||||
...e,
|
||||
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
|
||||
font: { color: '#7b82a6', size: 10, align: 'middle' },
|
||||
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'curvedCW', roundness: 0.1 },
|
||||
}))
|
||||
@@ -264,6 +264,29 @@ function toggleFilter(type) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyThemeToNetwork() {
|
||||
if (!nodesDS || !edgesDS || !network.value) return
|
||||
const palette = graphPalette()
|
||||
nodesDS.update(
|
||||
nodes.value.map((n) => ({
|
||||
id: String(n.id),
|
||||
color: {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeBorder,
|
||||
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
|
||||
},
|
||||
font: { color: palette.nodeFont, size: 13 },
|
||||
}))
|
||||
)
|
||||
edgesDS.update(
|
||||
filteredEdges().map((e) => ({
|
||||
id: String(e.id),
|
||||
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
||||
}))
|
||||
)
|
||||
network.value.redraw()
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
network.value?.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } })
|
||||
}
|
||||
@@ -273,10 +296,15 @@ function togglePhysics() {
|
||||
network.value?.setOptions({ physics: { enabled: physicsEnabled.value } })
|
||||
}
|
||||
|
||||
onMounted(loadGraph)
|
||||
onMounted(() => {
|
||||
loadGraph()
|
||||
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (initRetryTimer) clearTimeout(initRetryTimer)
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
network.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
@@ -289,17 +317,6 @@ onUnmounted(() => {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.graph-view-header {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 28px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.graph-view-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.graph-view-toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28px 10px;
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<div class="page-header">
|
||||
<h2>Импорт контактов</h2>
|
||||
</div>
|
||||
<div class="page-content" style="max-width:640px;">
|
||||
<div class="page-content content-narrow">
|
||||
<div class="card">
|
||||
<h3 style="font-size:14px;margin-bottom:6px;">Загрузить файл</h3>
|
||||
<p class="text-muted" style="margin-bottom:18px;">
|
||||
<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>.
|
||||
</p>
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '../api'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
|
||||
const store = useContactsStore()
|
||||
@@ -99,15 +98,9 @@ async function doImport() {
|
||||
importing.value = true
|
||||
result.value = null
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', selectedFile.value)
|
||||
const { data } = await api.post('/import/', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
result.value = data
|
||||
await store.fetchContacts()
|
||||
result.value = await store.importContacts(selectedFile.value)
|
||||
} catch (e) {
|
||||
result.value = { error: e.response?.data?.error || e.message }
|
||||
result.value = { error: e.message }
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
@@ -121,6 +114,16 @@ function reset() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-narrow {
|
||||
max-width: 640px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.section-subtitle {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
<template>
|
||||
<div class="network-map-view">
|
||||
<NetworkMapTopPanel
|
||||
:collapsed="topPanelCollapsed"
|
||||
@toggle-collapse="topPanelCollapsed = !topPanelCollapsed"
|
||||
@fit="fitView"
|
||||
>
|
||||
<template #filters>
|
||||
<RelationTypeFilters
|
||||
:relation-types="allRelationTypes"
|
||||
:active-values="activeFilters"
|
||||
@toggle="toggleFilter"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #legend>
|
||||
<MapLegendPanel />
|
||||
</template>
|
||||
</NetworkMapTopPanel>
|
||||
|
||||
<div class="network-map-body">
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card">
|
||||
<p>
|
||||
На карте сети никого нет. Отметьте «Показывать на карте сети» в
|
||||
<RouterLink to="/contacts">карточках контактов</RouterLink>
|
||||
или добавьте новых.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="map-stack" ref="mapStack">
|
||||
<div id="network-map-container" ref="graphContainer" class="map-vis"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNode" class="modal-overlay" @click.self="selectedNode = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ selectedNode.label }}</h3>
|
||||
<button class="btn btn-secondary btn-sm" @click="selectedNode = null">✕</button>
|
||||
</div>
|
||||
<div v-if="selectedContact">
|
||||
<div class="form-group">
|
||||
<label>Сфера / круг / важность</label>
|
||||
<div>
|
||||
{{ sphereLabels[selectedContact.life_sphere] || selectedContact.life_sphere }}
|
||||
· {{ circleLabels[selectedContact.network_circle] || selectedContact.network_circle }}
|
||||
· {{ selectedContact.importance }}/5
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Организация</label>
|
||||
<div>{{ [selectedContact.organization, selectedContact.position].filter(Boolean).join(' · ') || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<RouterLink :to="`/contacts/${selectedNode.id}`" class="btn btn-primary btn-sm">Открыть карточку</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||
import {
|
||||
SPHERE_ORDER,
|
||||
ringRadius,
|
||||
ringByRadius,
|
||||
sphereByAngle,
|
||||
computePolarPositions,
|
||||
nodeXY,
|
||||
} from '../lib/map/positioning'
|
||||
import { fetchGraphBundle, fetchMapChoices } from '../composables/useGraphData'
|
||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||
import MapLegendPanel from '../components/MapLegendPanel.vue'
|
||||
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
||||
const GRID_SCALE = 5
|
||||
|
||||
function nodeById(id) {
|
||||
return nodes.value.find((n) => String(n.id) === String(id))
|
||||
}
|
||||
|
||||
async function persistNodePlacement(nodeId, canvasX, canvasY) {
|
||||
const node = nodeById(nodeId)
|
||||
if (!node) return
|
||||
const L = layout.value
|
||||
const dx = canvasX - L.cx
|
||||
const dy = canvasY - L.cy
|
||||
const r = Math.sqrt(dx * dx + dy * dy)
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const ratio = Math.max(0, Math.min(1, r / (L.rOuter || 1)))
|
||||
const nextSphere = sphereByAngle(angle)
|
||||
const nextCircle = ringByRadius(r, L)
|
||||
|
||||
if (
|
||||
node.life_sphere === nextSphere &&
|
||||
node.network_circle === nextCircle &&
|
||||
Math.abs((Number(node.map_angle) || 0) - angle) < 1e-6 &&
|
||||
Math.abs((Number(node.map_radius_ratio) || 0) - ratio) < 1e-6
|
||||
) {
|
||||
refreshPositions()
|
||||
return
|
||||
}
|
||||
|
||||
node.life_sphere = nextSphere
|
||||
node.network_circle = nextCircle
|
||||
node.map_angle = angle
|
||||
node.map_radius_ratio = ratio
|
||||
refreshPositions()
|
||||
|
||||
try {
|
||||
await store.updateContact(Number(node.id), {
|
||||
life_sphere: nextSphere,
|
||||
network_circle: nextCircle,
|
||||
map_angle: angle,
|
||||
map_radius_ratio: ratio,
|
||||
})
|
||||
} catch (e) {
|
||||
// Если PATCH не прошел — откатываем карту к данным из API.
|
||||
await store.fetchContacts()
|
||||
const actual = store.contactById(Number(node.id))
|
||||
if (actual) {
|
||||
node.life_sphere = actual.life_sphere
|
||||
node.network_circle = actual.network_circle
|
||||
node.map_angle = actual.map_angle
|
||||
node.map_radius_ratio = actual.map_radius_ratio
|
||||
refreshPositions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function importanceSize(importance) {
|
||||
// Защита от "грязных" данных в БД: размер точки всегда в разумном диапазоне.
|
||||
const raw = Number(importance)
|
||||
const i = Number.isFinite(raw) ? Math.max(1, Math.min(5, raw)) : 3
|
||||
return 8 + (i - 1) * 3
|
||||
}
|
||||
|
||||
function initials(name) {
|
||||
return String(name || '')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() || '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
function computeLabelBySpace(name, minScreenDistance) {
|
||||
if (minScreenDistance >= 120) return name
|
||||
if (minScreenDistance >= 64) return initials(name)
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildLabelMap(posById, scale = 1) {
|
||||
const map = new Map()
|
||||
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1
|
||||
const arranged = nodes.value.map((n) => ({
|
||||
id: n.id,
|
||||
name: n.label || String(n.id),
|
||||
pos: posById.get(n.id),
|
||||
})).filter((x) => x.pos)
|
||||
|
||||
for (const item of arranged) {
|
||||
let minDist = Number.POSITIVE_INFINITY
|
||||
for (const other of arranged) {
|
||||
if (other.id === item.id) continue
|
||||
const dx = item.pos.x - other.pos.x
|
||||
const dy = item.pos.y - other.pos.y
|
||||
const d = Math.sqrt(dx * dx + dy * dy)
|
||||
if (d < minDist) minDist = d
|
||||
}
|
||||
const minScreen = Number.isFinite(minDist) ? minDist * safeScale : 9999
|
||||
map.set(item.id, computeLabelBySpace(item.name, minScreen))
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
const store = useContactsStore()
|
||||
const mapStack = ref(null)
|
||||
const graphContainer = ref(null)
|
||||
const loading = ref(true)
|
||||
const network = ref(null)
|
||||
const selectedNode = ref(null)
|
||||
const nodes = ref([])
|
||||
const edges = ref([])
|
||||
const allRelationTypes = ref([])
|
||||
const activeFilters = ref([])
|
||||
const layout = ref({ w: 0, h: 0, cx: 0, cy: 0, rInner: 0, rMid: 0, rOuter: 0 })
|
||||
const sphereLabels = ref({})
|
||||
const circleLabels = ref({})
|
||||
|
||||
let nodesDS = null
|
||||
let edgesDS = null
|
||||
let resizeObserver = null
|
||||
let initRetryTimer = null
|
||||
let initRetryCount = 0
|
||||
const INIT_RETRY_MAX = 40
|
||||
let themeObserver = null
|
||||
|
||||
const selectedContact = computed(() =>
|
||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||
)
|
||||
const topPanelCollapsed = ref(false)
|
||||
|
||||
function cssVar(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
return value || fallback
|
||||
}
|
||||
|
||||
function mapPalette() {
|
||||
return {
|
||||
nodeBackground: cssVar('--surface-alt', '#22263a'),
|
||||
nodeBorder: cssVar('--accent', '#5b8dee'),
|
||||
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
|
||||
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
|
||||
nodeFont: cssVar('--text', '#e2e6f3'),
|
||||
guideText: cssVar('--text-muted', '#7b82a6'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Сетка в тех же мировых координатах, что и узлы (см. vis-network beforeDrawing после translate+scale). */
|
||||
function drawPolarGuide(ctx) {
|
||||
const palette = mapPalette()
|
||||
const L = layout.value
|
||||
const net = network.value
|
||||
if (!L.rOuter || !net) return
|
||||
const { cx, cy, rInner, rMid, rOuter } = L
|
||||
const n = SPHERE_ORDER.length
|
||||
|
||||
// Полупрозрачная заливка колец (от внешнего к внутреннему)
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, rOuter, 0, 2 * Math.PI)
|
||||
ctx.arc(cx, cy, rMid, 0, 2 * Math.PI, true)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = 'rgba(91, 141, 238, 0.08)'
|
||||
ctx.fill()
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, rMid, 0, 2 * Math.PI)
|
||||
ctx.arc(cx, cy, rInner, 0, 2 * Math.PI, true)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = 'rgba(78, 204, 163, 0.08)'
|
||||
ctx.fill()
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, rInner, 0, 2 * Math.PI)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = 'rgba(244, 162, 97, 0.09)'
|
||||
ctx.fill()
|
||||
|
||||
ctx.strokeStyle = 'rgba(123, 130, 166, 0.55)'
|
||||
ctx.lineWidth = 1
|
||||
for (const r of [rInner, rMid, rOuter]) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(123, 130, 166, 0.4)'
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 0; i <= n; i += 1) {
|
||||
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy)
|
||||
ctx.lineTo(cx + rOuter * Math.cos(a), cy + rOuter * Math.sin(a))
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.fillStyle = palette.guideText
|
||||
ctx.font = '12px system-ui, -apple-system, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
const labelR = rOuter + 36
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const mid = ((i + 0.5) / n) * 2 * Math.PI - Math.PI / 2
|
||||
const key = SPHERE_ORDER[i]
|
||||
const text = sphereLabels.value[key] || key
|
||||
ctx.fillText(text, cx + labelR * Math.cos(mid), cy + labelR * Math.sin(mid))
|
||||
}
|
||||
|
||||
// Подписи кругов
|
||||
const supportR = rInner * 0.55
|
||||
const productivityR = (rInner + rMid) / 2
|
||||
const developmentR = (rMid + rOuter) / 2
|
||||
ctx.fillStyle = palette.guideText
|
||||
ctx.font = '13px system-ui, -apple-system, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('Круг поддержки', cx + 12, cy - supportR)
|
||||
ctx.fillText('Круг продуктивности', cx + 12, cy - productivityR)
|
||||
ctx.fillText('Круг развития', cx + 12, cy - developmentR)
|
||||
}
|
||||
|
||||
function measureLayout() {
|
||||
const el = graphContainer.value
|
||||
if (!el) return
|
||||
const w = el.offsetWidth
|
||||
const h = el.offsetHeight
|
||||
if (w < 10 || h < 10) return
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
const maxR = Math.min(w, h) * 0.36 * GRID_SCALE
|
||||
layout.value = {
|
||||
w,
|
||||
h,
|
||||
cx,
|
||||
cy,
|
||||
rInner: maxR * 0.33,
|
||||
rMid: maxR * 0.62,
|
||||
rOuter: maxR * 0.92,
|
||||
}
|
||||
}
|
||||
|
||||
function filteredEdges() {
|
||||
const nodeIds = new Set(nodes.value.map((n) => n.id))
|
||||
let list = edges.value.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to))
|
||||
if (activeFilters.value.length < allRelationTypes.value.length) {
|
||||
list = list.filter((e) => activeFilters.value.includes(e.relation_type))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
function mapEdgeToVis(e) {
|
||||
const intense = e.interaction_intensity !== 'sparse'
|
||||
const tip = [e.label, e.title].filter(Boolean).join(' — ') || 'Связь'
|
||||
return {
|
||||
id: String(e.id),
|
||||
from: String(e.from),
|
||||
to: String(e.to),
|
||||
title: tip,
|
||||
relation_type: e.relation_type,
|
||||
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
|
||||
width: intense ? 2.2 : 1,
|
||||
dashes: intense ? false : [8, 6],
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.65 } },
|
||||
smooth: false,
|
||||
}
|
||||
}
|
||||
|
||||
function buildVisNodes() {
|
||||
const L = layout.value
|
||||
const posById = computePolarPositions(nodes.value, L)
|
||||
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
|
||||
const palette = mapPalette()
|
||||
return nodes.value.map((n) => {
|
||||
const { x, y } = nodeXY(n, L, posById)
|
||||
const name = n.label || String(n.id)
|
||||
return {
|
||||
id: String(n.id),
|
||||
label: labelMap.get(n.id) || '',
|
||||
title: [name, n.title].filter(Boolean).join('\n'),
|
||||
x: L.cx + x,
|
||||
y: L.cy + y,
|
||||
fixed: false,
|
||||
color: {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeBorder,
|
||||
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
|
||||
},
|
||||
font: { color: palette.nodeFont, size: 11 },
|
||||
shape: 'dot',
|
||||
size: importanceSize(n.importance),
|
||||
label: labelMap.get(n.id) || '',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function initNetwork() {
|
||||
if (!graphContainer.value) return
|
||||
measureLayout()
|
||||
const el = graphContainer.value
|
||||
let { w, h } = layout.value
|
||||
if (w < 10 || h < 10) {
|
||||
if (initRetryCount >= INIT_RETRY_MAX) {
|
||||
el.style.minHeight = '400px'
|
||||
measureLayout()
|
||||
w = layout.value.w
|
||||
h = layout.value.h
|
||||
} else {
|
||||
initRetryCount += 1
|
||||
initRetryTimer = setTimeout(initNetwork, 80)
|
||||
return
|
||||
}
|
||||
}
|
||||
initRetryCount = 0
|
||||
if (initRetryTimer) {
|
||||
clearTimeout(initRetryTimer)
|
||||
initRetryTimer = null
|
||||
}
|
||||
|
||||
network.value?.destroy()
|
||||
network.value = null
|
||||
|
||||
const nodeList = buildVisNodes()
|
||||
const edgeList = filteredEdges().map(mapEdgeToVis)
|
||||
nodesDS = new DataSet(nodeList)
|
||||
edgesDS = new DataSet(edgeList)
|
||||
|
||||
network.value = new Network(
|
||||
el,
|
||||
{ nodes: nodesDS, edges: edgesDS },
|
||||
{
|
||||
physics: false,
|
||||
interaction: {
|
||||
tooltipDelay: 150,
|
||||
hover: true,
|
||||
zoomView: true,
|
||||
dragView: true,
|
||||
dragNodes: true,
|
||||
selectable: true,
|
||||
},
|
||||
nodes: { borderWidth: 1.5 },
|
||||
edges: { font: { size: 0 } },
|
||||
}
|
||||
)
|
||||
|
||||
network.value.on('beforeDrawing', drawPolarGuide)
|
||||
|
||||
network.value.on('click', (params) => {
|
||||
if (params.nodes.length > 0) {
|
||||
const id = params.nodes[0]
|
||||
const node = nodes.value.find((n) => String(n.id) === id)
|
||||
selectedNode.value = node || null
|
||||
}
|
||||
})
|
||||
|
||||
network.value.on('dragEnd', async (params) => {
|
||||
if (!params.nodes?.length) return
|
||||
const id = params.nodes[0]
|
||||
const p = network.value?.getPositions([id])?.[id]
|
||||
if (!p) return
|
||||
await persistNodePlacement(id, p.x, p.y)
|
||||
})
|
||||
|
||||
network.value.on('zoom', () => {
|
||||
refreshLabelsByZoom()
|
||||
})
|
||||
|
||||
nextTick(() => {
|
||||
network.value?.fit({ animation: false, padding: 56 })
|
||||
})
|
||||
}
|
||||
|
||||
function refreshPositions() {
|
||||
measureLayout()
|
||||
if (!nodesDS || !network.value) return
|
||||
const L = layout.value
|
||||
const posById = computePolarPositions(nodes.value, L)
|
||||
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
|
||||
const updates = nodes.value.map((n) => {
|
||||
const { x, y } = nodeXY(n, L, posById)
|
||||
return {
|
||||
id: String(n.id),
|
||||
x: L.cx + x,
|
||||
y: L.cy + y,
|
||||
fixed: false,
|
||||
size: importanceSize(n.importance),
|
||||
label: labelMap.get(n.id) || '',
|
||||
}
|
||||
})
|
||||
nodesDS.update(updates)
|
||||
network.value.redraw()
|
||||
}
|
||||
|
||||
|
||||
|
||||
function refreshLabelsByZoom() {
|
||||
if (!nodesDS || !network.value) return
|
||||
const L = layout.value
|
||||
const posById = computePolarPositions(nodes.value, L)
|
||||
const labelMap = buildLabelMap(posById, network.value.getScale())
|
||||
const palette = mapPalette()
|
||||
const updates = nodes.value.map((n) => ({
|
||||
id: String(n.id),
|
||||
label: labelMap.get(n.id) || '',
|
||||
font: { color: palette.nodeFont, size: 11 },
|
||||
}))
|
||||
nodesDS.update(updates)
|
||||
network.value.redraw()
|
||||
}
|
||||
|
||||
function applyThemeToNetwork() {
|
||||
if (!nodesDS || !network.value) return
|
||||
const palette = mapPalette()
|
||||
nodesDS.update(
|
||||
nodes.value.map((n) => ({
|
||||
id: String(n.id),
|
||||
color: {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeBorder,
|
||||
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
|
||||
},
|
||||
font: { color: palette.nodeFont, size: 11 },
|
||||
}))
|
||||
)
|
||||
network.value.redraw()
|
||||
}
|
||||
|
||||
function refreshEdges() {
|
||||
if (!edgesDS) return
|
||||
edgesDS.clear()
|
||||
edgesDS.add(filteredEdges().map(mapEdgeToVis))
|
||||
}
|
||||
|
||||
function toggleFilter(type) {
|
||||
if (activeFilters.value.includes(type)) {
|
||||
if (activeFilters.value.length === 1) return
|
||||
activeFilters.value = activeFilters.value.filter((f) => f !== type)
|
||||
} else {
|
||||
activeFilters.value.push(type)
|
||||
}
|
||||
refreshEdges()
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [bundle, mapChoices] = await Promise.all([
|
||||
fetchGraphBundle('/network-map-graph/'),
|
||||
fetchMapChoices(),
|
||||
])
|
||||
nodes.value = bundle.nodes
|
||||
edges.value = bundle.edges
|
||||
allRelationTypes.value = bundle.relationTypes
|
||||
activeFilters.value = bundle.relationTypes.map((r) => r.value)
|
||||
const sm = {}
|
||||
for (const o of mapChoices.life_spheres) sm[o.value] = o.label
|
||||
sphereLabels.value = sm
|
||||
const cm = {}
|
||||
for (const o of mapChoices.network_circles) cm[o.value] = o.label
|
||||
circleLabels.value = cm
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
if (nodes.value.length > 0) {
|
||||
await store.fetchContacts()
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
initNetwork()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
await nextTick()
|
||||
const stack = mapStack.value
|
||||
if (stack) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
refreshPositions()
|
||||
network.value?.redraw()
|
||||
})
|
||||
resizeObserver.observe(stack)
|
||||
}
|
||||
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (initRetryTimer) clearTimeout(initRetryTimer)
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
network.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.network-map-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 28px 20px;
|
||||
position: relative;
|
||||
}
|
||||
.map-stack {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.map-vis {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.js'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user