WIP: local changes

This commit is contained in:
2026-04-08 15:29:52 +03:00
parent 07842540ba
commit 81ba6cd076
56 changed files with 2015 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
# Social Graph Builder
Построитель социального графа: импорт контактов, визуализация связей, CRUD.
## Стек
| Слой | Технология |
|------|-----------|
| Backend API | Django 4.2 + Django REST Framework |
| База данных | SQLite |
| Frontend | Vue.js 3 + Vite |
| Граф | vis-network |
| Состояние | Pinia |
| Запуск | Docker Compose |
## Быстрый старт
```bash
git clone <repo>
cd social-graph
docker-compose up --build
```
- Frontend: http://localhost:5173
- Backend API: http://localhost:8000/api/
## API endpoints
| Метод | URL | Описание |
|-------|-----|----------|
| GET/POST | `/api/contacts/` | Список / создание контактов |
| GET/PATCH/DELETE | `/api/contacts/{id}/` | Контакт по ID |
| GET/POST | `/api/relations/` | Список / создание связей |
| DELETE | `/api/relations/{id}/` | Удалить связь |
| GET | `/api/graph/` | Граф (nodes + edges для vis.js) |
| GET | `/api/relation-types/` | Типы связей |
| POST | `/api/import/` | Импорт CSV/JSON |
## Формат CSV для импорта
```csv
name,email,phone,organization,position,notes
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,
Мария Петрова,maria@example.com,+7-900-000-0002,Газпром,Аналитик,
```
## Формат JSON для импорта
```json
[
{"name": "Иван Иванов", "email": "ivan@example.com", "organization": "ООО Ромашка"},
{"name": "Мария Петрова", "phone": "+7-900-000-0002"}
]
```
## Структура проекта
```
social-graph/
├── backend/
│ ├── config/ # Django settings, urls
│ ├── contacts/ # models, serializers, views, urls
│ ├── manage.py
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── views/ # GraphView, ContactsView, ContactDetailView, ImportView
│ │ ├── components/ # ContactForm
│ │ ├── stores/ # Pinia store (contacts)
│ │ ├── router/ # Vue Router
│ │ ├── api.js # Axios instance
│ │ └── App.vue
│ ├── vite.config.js
│ └── Dockerfile
└── docker-compose.yml
```
## Запуск без Docker
**Backend:**
```bash
cd backend
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev
```
> В `vite.config.js` прокси настроен на `http://backend:8000`.
> При локальном запуске без Docker замените на `http://localhost:8000`.
## Планируемые фичи (следующие итерации)
- [ ] Авторизация (Django auth + JWT)
- [ ] Теги/группы контактов
- [ ] Экспорт в CSV/JSON
- [ ] Поиск по организации и должности
- [ ] История изменений контакта
- [ ] Импорт из vCard (.vcf)
- [ ] Уведомления / дни рождения
+5
View File
@@ -0,0 +1,5 @@
__pycache__
*.pyc
*.pyo
db.sqlite3
.env
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Миграции и запуск
CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"]
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'django-insecure-social-graph-dev-key-change-in-production'
DEBUG = True
ALLOWED_HOSTS = ['*']
INSTALLED_APPS = [
'django.contrib.contenttypes',
'django.contrib.auth',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'contacts',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
]
ROOT_URLCONF = 'config.urls'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 100,
}
CORS_ALLOW_ALL_ORIGINS = True
# Лимит тела запроса для импорта больших JSON (например, экспорт Monica с сотнями контактов)
DATA_UPLOAD_MAX_MEMORY_SIZE = 20 * 1024 * 1024 # 20 MB
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+5
View File
@@ -0,0 +1,5 @@
from django.urls import path, include
urlpatterns = [
path('api/', include('contacts.urls')),
]
View File
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,50 @@
# Generated by Django 4.2.9 on 2026-03-14 09:21
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Contact',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Имя')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=50, verbose_name='Телефон')),
('organization', models.CharField(blank=True, max_length=255, verbose_name='Организация')),
('position', models.CharField(blank=True, max_length=255, verbose_name='Должность')),
('notes', models.TextField(blank=True, verbose_name='Заметки')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Контакт',
'verbose_name_plural': 'Контакты',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Relation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('relation_type', models.CharField(choices=[('colleague', 'Коллега'), ('friend', 'Друг'), ('family', 'Родственник'), ('acquaintance', 'Знакомый'), ('business', 'Деловой партнёр'), ('other', 'Другое')], default='acquaintance', max_length=50, verbose_name='Тип связи')),
('description', models.CharField(blank=True, max_length=255, verbose_name='Описание')),
('created_at', models.DateTimeField(auto_now_add=True)),
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='relations_as_source', to='contacts.contact', verbose_name='Источник')),
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='relations_as_target', to='contacts.contact', verbose_name='Цель')),
],
options={
'verbose_name': 'Связь',
'verbose_name_plural': 'Связи',
'unique_together': {('source', 'target')},
},
),
]
+65
View File
@@ -0,0 +1,65 @@
from django.db import models
class Contact(models.Model):
"""Контакт в социальном графе."""
name = models.CharField(max_length=255, verbose_name='Имя')
email = models.EmailField(blank=True, verbose_name='Email')
phone = models.CharField(max_length=50, blank=True, verbose_name='Телефон')
organization = models.CharField(max_length=255, blank=True, verbose_name='Организация')
position = models.CharField(max_length=255, blank=True, verbose_name='Должность')
notes = models.TextField(blank=True, verbose_name='Заметки')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
verbose_name = 'Контакт'
verbose_name_plural = 'Контакты'
def __str__(self):
return self.name
RELATION_TYPES = [
('colleague', 'Коллега'),
('friend', 'Друг'),
('family', 'Родственник'),
('acquaintance', 'Знакомый'),
('business', 'Деловой партнёр'),
('other', 'Другое'),
]
class Relation(models.Model):
"""Связь между двумя контактами."""
source = models.ForeignKey(
Contact,
on_delete=models.CASCADE,
related_name='relations_as_source',
verbose_name='Источник',
)
target = models.ForeignKey(
Contact,
on_delete=models.CASCADE,
related_name='relations_as_target',
verbose_name='Цель',
)
relation_type = models.CharField(
max_length=50,
choices=RELATION_TYPES,
default='acquaintance',
verbose_name='Тип связи',
)
description = models.CharField(max_length=255, blank=True, verbose_name='Описание')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('source', 'target')
verbose_name = 'Связь'
verbose_name_plural = 'Связи'
def __str__(self):
return f'{self.source}{self.target} ({self.relation_type})'
+74
View File
@@ -0,0 +1,74 @@
from rest_framework import serializers
from .models import Contact, Relation
class ContactSerializer(serializers.ModelSerializer):
relations_count = serializers.SerializerMethodField()
class Meta:
model = Contact
fields = [
'id', 'name', 'email', 'phone',
'organization', 'position', 'notes',
'created_at', 'updated_at', 'relations_count',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'relations_count']
def get_relations_count(self, obj):
return (
obj.relations_as_source.count() +
obj.relations_as_target.count()
)
class RelationSerializer(serializers.ModelSerializer):
source_name = serializers.CharField(source='source.name', read_only=True)
target_name = serializers.CharField(source='target.name', read_only=True)
class Meta:
model = Relation
fields = [
'id', 'source', 'source_name',
'target', 'target_name',
'relation_type', 'description', 'created_at',
]
read_only_fields = ['id', 'created_at', 'source_name', 'target_name']
def validate(self, data):
if data.get('source') == data.get('target'):
raise serializers.ValidationError(
'Нельзя создать связь контакта с самим собой.'
)
return data
class GraphSerializer(serializers.Serializer):
"""Граф для vis.js: nodes + edges."""
nodes = serializers.SerializerMethodField()
edges = serializers.SerializerMethodField()
def get_nodes(self, obj):
contacts = Contact.objects.all()
return [
{
'id': c.id,
'label': c.name,
'title': f'{c.organization}\n{c.position}'.strip() or c.name,
'group': c.organization or 'default',
}
for c in contacts
]
def get_edges(self, obj):
relations = Relation.objects.select_related('source', 'target').all()
return [
{
'id': r.id,
'from': r.source_id,
'to': r.target_id,
'label': r.get_relation_type_display(),
'title': r.description or r.get_relation_type_display(),
}
for r in relations
]
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register('contacts', views.ContactViewSet)
router.register('relations', views.RelationViewSet)
urlpatterns = [
path('', include(router.urls)),
path('graph/', views.graph_data),
path('relation-types/', views.relation_types),
path('import/', views.import_contacts),
]
+249
View File
@@ -0,0 +1,249 @@
import csv
import io
import json
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 .serializers import ContactSerializer, RelationSerializer, GraphSerializer
class ContactViewSet(viewsets.ModelViewSet):
queryset = Contact.objects.all()
serializer_class = ContactSerializer
def get_queryset(self):
qs = super().get_queryset()
q = self.request.query_params.get('search', '')
if q:
qs = qs.filter(name__icontains=q)
return qs
class RelationViewSet(viewsets.ModelViewSet):
queryset = Relation.objects.select_related('source', 'target').all()
serializer_class = RelationSerializer
@api_view(['GET'])
def graph_data(request):
"""Возвращает граф: nodes + edges для vis.js."""
contacts = Contact.objects.all()
nodes = [
{
'id': c.id,
'label': c.name,
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
'group': c.organization or 'default',
}
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,
}
for r in relations
]
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])
def _monica_contact_fields(contact_data):
"""Из вложенного data контакта Monica (экспорт account.data) извлекает телефон, email, заметки."""
phone = ''
email = ''
notes_parts = []
for block in contact_data or []:
if block.get('type') == 'contact_field':
for val in block.get('values') or []:
props = val.get('properties') or {}
value = str(props.get('data') or '').strip()
if not value:
continue
if '@' in value and '.' in value:
email = email or value
else:
phone = phone or value
elif block.get('type') == 'note':
for val in block.get('values') or []:
body = str((val.get('properties') or {}).get('body') or '').strip()
if body:
notes_parts.append(body)
return phone, email, '\n'.join(notes_parts)
def _normalize_rows(data):
"""
Нормализует различные форматы JSON в плоский список словарей.
Поддерживает:
- Плоский массив: [{"name": ...}, ...]
- Monica CRM (экспорт): {"account": {"data": [{"type": "contact", "values": [...]}]}}
- Monica CRM (старый): {"contacts": [{"first_name": ..., "last_name": ...}, ...]}
- Обёртка results: {"results": [...]}
- Обёртка data: {"data": [...]}
"""
if isinstance(data, list):
return data
if isinstance(data, dict):
# Monica CRM полный экспорт: account.data, блоки type=contact, values[].properties + data
account = data.get('account')
if isinstance(account, dict):
account_data = account.get('data')
if isinstance(account_data, list):
rows = []
for block in account_data:
if block.get('type') != 'contact':
continue
for c in block.get('values') or []:
props = c.get('properties') or {}
first = str(props.get('first_name') or '').strip()
last = str(props.get('last_name') or '').strip()
middle = str(props.get('middle_name') or '').strip()
name = ' '.join(filter(None, [first, middle, last])) or ' '.join(
filter(None, [first, last])
)
if not name:
continue
phone, email, notes = _monica_contact_fields(c.get('data'))
rows.append({
'name': name,
'email': email,
'phone': phone,
'organization': '',
'position': '',
'notes': notes,
})
if rows:
return rows
# Monica CRM: ключ "contacts" с first_name/last_name (старый формат API)
if 'contacts' in data:
rows = []
for c in data['contacts']:
first = str(c.get('first_name') or '').strip()
last = str(c.get('last_name') or '').strip()
name = ' '.join(filter(None, [first, last]))
# Телефоны Monica хранятся в списке phone_numbers
phone = ''
for ph in c.get('phone_numbers') or []:
phone = str(ph.get('number') or ph.get('content') or '')
if phone:
break
# Email Monica — список emails
email = ''
for em in c.get('emails') or []:
email = str(em.get('email') or em.get('content') or '')
if email:
break
# Организации Monica — список companies
org = ''
position = ''
for comp in c.get('companies') or []:
org = str(comp.get('name') or comp.get('company_name') or '')
position = str(comp.get('job') or comp.get('position') or comp.get('title') or '')
if org:
break
# Также бывает прямое поле company
if not org:
org = str(c.get('company') or c.get('company_name') or '').strip()
position = str(c.get('job') or c.get('position') or '').strip()
rows.append({
'name': name,
'email': email,
'phone': phone,
'organization': org,
'position': position,
'notes': str(c.get('information') or c.get('description') or c.get('notes') or '').strip(),
})
return rows
# Другие обёртки
for key in ('results', 'data', 'items', 'people', 'persons'):
if key in data and isinstance(data[key], list):
return data[key]
return []
@api_view(['POST'])
def import_contacts(request):
"""
Импорт контактов из CSV или JSON.
CSV: name,email,phone,organization,position,notes
JSON (плоский): [{"name": "...", ...}, ...]
JSON (Monica CRM): {"contacts": [{"first_name": ..., "last_name": ...}, ...]}
"""
file = request.FILES.get('file')
if not file:
return Response({'error': 'Файл не передан.'}, status=status.HTTP_400_BAD_REQUEST)
filename = file.name.lower()
created = 0
skipped = 0
errors = []
try:
if filename.endswith('.csv'):
text = file.read().decode('utf-8-sig')
reader = csv.DictReader(io.StringIO(text))
rows = list(reader)
elif filename.endswith('.json'):
raw = json.loads(file.read().decode('utf-8'))
rows = _normalize_rows(raw)
if not rows:
return Response(
{'error': 'Не удалось распознать формат JSON. Ожидается массив контактов или экспорт Monica CRM.'},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{'error': 'Поддерживаются только CSV и JSON файлы.'},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
return Response({'error': f'Ошибка разбора файла: {e}'}, status=status.HTTP_400_BAD_REQUEST)
total_rows = len(rows)
for i, row in enumerate(rows):
name = str(
row.get('name') or row.get('Name') or row.get('ФИО') or
' '.join(filter(None, [
str(row.get('first_name') or '').strip(),
str(row.get('last_name') or '').strip(),
]))
).strip()
if not name:
errors.append(f'Строка {i + 1}: отсутствует поле "name"')
skipped += 1
continue
Contact.objects.get_or_create(
name=name,
defaults={
'email': str(row.get('email') or '').strip(),
'phone': str(row.get('phone') or '').strip(),
'organization': str(row.get('organization') or '').strip(),
'position': str(row.get('position') or '').strip(),
'notes': str(row.get('notes') or '').strip(),
},
)
created += 1
return Response({
'total': total_rows,
'created': created,
'skipped': skipped,
'errors': errors,
})
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python
import os
import sys
if __name__ == '__main__':
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
+4
View File
@@ -0,0 +1,4 @@
Django==4.2.9
djangorestframework==3.14.0
django-cors-headers==4.3.1
Pillow==10.2.0
+33
View File
@@ -0,0 +1,33 @@
version: "3.9"
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: sg_backend
volumes:
- ./backend:/app
- sqlite_data:/app/data
environment:
- DJANGO_SETTINGS_MODULE=config.settings
ports:
- "8000:8000"
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: sg_frontend
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "5173:5173"
depends_on:
- backend
restart: unless-stopped
volumes:
sqlite_data:
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Social Graph Builder</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "social-graph-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"axios": "^1.6.7",
"vis-network": "^9.1.9",
"vis-data": "^7.1.9"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.1.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1a1d27"/>
<circle cx="7" cy="16" r="4" fill="#5b8dee"/>
<circle cx="25" cy="8" r="4" fill="#4ecca3"/>
<circle cx="25" cy="24" r="4" fill="#f4a261"/>
<line x1="11" y1="14" x2="21" y2="10" stroke="#5b8dee" stroke-width="1.5"/>
<line x1="11" y1="18" x2="21" y2="22" stroke="#5b8dee" stroke-width="1.5"/>
<line x1="21" y1="10" x2="21" y2="22" stroke="#7b82a6" stroke-width="1" stroke-dasharray="2,2"/>
</svg>

After

Width:  |  Height:  |  Size: 523 B

+67
View File
@@ -0,0 +1,67 @@
<template>
<div class="layout">
<!-- Sidebar -->
<aside class="sidebar">
<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;">
<circle cx="5" cy="12" r="3" fill="#5b8dee"/>
<circle cx="19" cy="5" r="3" fill="#4ecca3"/>
<circle cx="19" cy="19" r="3" fill="#f4a261"/>
<line x1="8" y1="12" x2="16" y2="7" stroke="#5b8dee" stroke-width="1.5"/>
<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
</h1>
<span>Построитель социального графа</span>
</div>
<nav>
<RouterLink to="/graph" class="nav-link" active-class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
Граф
</RouterLink>
<RouterLink to="/contacts" class="nav-link" active-class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<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>
Контакты
</RouterLink>
<RouterLink to="/import" class="nav-link" active-class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Импорт
</RouterLink>
</nav>
<div class="sidebar-stats">
<div class="stat">Контактов: <strong>{{ store.totalContacts }}</strong></div>
<div class="stat">Связей: <strong>{{ store.totalRelations }}</strong></div>
</div>
</aside>
<!-- Main content -->
<main class="main">
<RouterView />
</main>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
import { useContactsStore } from './stores/contacts'
const store = useContactsStore()
onMounted(async () => {
await store.fetchContacts()
await store.fetchRelations()
})
</script>
+8
View File
@@ -0,0 +1,8 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
})
export default api
+235
View File
@@ -0,0 +1,235 @@
/* ===== Reset & base ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface-alt: #22263a;
--border: #2e3354;
--accent: #5b8dee;
--accent-hover: #7aa5f5;
--accent-dim: rgba(91, 141, 238, 0.15);
--text: #e2e6f3;
--text-muted: #7b82a6;
--text-dim: #4a5075;
--red: #e05c6b;
--green: #4ecca3;
--orange: #f4a261;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 4px 20px rgba(0,0,0,0.4);
--font: 'Inter', system-ui, -apple-system, sans-serif;
}
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%; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
/* ===== Layout ===== */
.layout { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: 220px;
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 20px 0;
}
.sidebar-logo {
padding: 0 20px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.sidebar-logo h1 { font-size: 15px; font-weight: 700; color: var(--text); }
.sidebar-logo span { font-size: 11px; color: var(--text-muted); display: block; margin-top: 2px; }
.sidebar nav { flex: 1; }
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
cursor: pointer;
border-left: 2px solid transparent;
}
.nav-link:hover { color: var(--text); background: var(--surface-alt); }
.nav-link.active { color: var(--accent); border-left-color: var(--accent); background: var(--accent-dim); }
.nav-link svg { width: 16px; height: 16px; flex-shrink: 0; }
.sidebar-stats {
padding: 16px 20px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
}
.sidebar-stats .stat { margin-bottom: 4px; }
.sidebar-stats strong { color: var(--accent); }
.main { flex: 1; overflow: auto; display: flex; flex-direction: column; }
.page-header {
padding: 20px 28px 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.page-header h2 { font-size: 18px; font-weight: 600; }
.page-content { padding: 20px 28px; flex: 1; }
/* ===== Buttons ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.15s;
line-height: 1.4;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--surface-alt); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
.btn-danger { background: transparent; color: var(--red); border: 1px solid transparent; }
.btn-danger:hover { background: rgba(224,92,107,0.12); border-color: var(--red); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ===== Forms ===== */
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
.form-control {
width: 100%;
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 8px 12px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.form-control:focus { border-color: var(--accent); }
.form-control::placeholder { color: var(--text-dim); }
textarea.form-control { resize: vertical; min-height: 80px; }
/* ===== Cards / Table ===== */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; }
.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; }
/* ===== Badge ===== */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
}
.badge-colleague { background: rgba(79,172,254,0.15); color: #4facfe; }
.badge-friend { background: rgba(78,204,163,0.15); color: #4ecca3; }
.badge-family { background: rgba(244,162,97,0.15); color: #f4a261; }
.badge-business { background: rgba(91,141,238,0.15); color: #5b8dee; }
.badge-acquaintance { background: rgba(123,130,166,0.15); color: #7b82a6; }
.badge-other { background: rgba(255,255,255,0.06); color: #9ba3c5; }
/* ===== Modal ===== */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 28px;
width: 460px;
max-width: 95vw;
box-shadow: var(--shadow);
}
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.modal-header h3 { font-size: 16px; font-weight: 600; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
/* ===== Search ===== */
.search-bar {
position: relative;
margin-bottom: 16px;
}
.search-bar input { padding-left: 34px; }
.search-bar .search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-dim);
pointer-events: none;
}
/* ===== Alert ===== */
.alert { padding: 10px 14px; border-radius: var(--radius-sm); font-size: 13px; margin-bottom: 14px; }
.alert-error { background: rgba(224,92,107,0.12); border: 1px solid rgba(224,92,107,0.3); color: var(--red); }
.alert-success { background: rgba(78,204,163,0.12); border: 1px solid rgba(78,204,163,0.3); color: var(--green); }
.alert-info { background: var(--accent-dim); border: 1px solid rgba(91,141,238,0.3); color: var(--accent-hover); }
/* ===== Empty state ===== */
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
.empty-state svg { width: 40px; height: 40px; opacity: 0.3; margin: 0 auto 12px; display: block; }
.empty-state p { font-size: 13px; }
/* ===== Loading ===== */
.spinner {
width: 22px; height: 22px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin: 60px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ===== Graph container ===== */
#graph-container {
width: 100%;
height: calc(100vh - 80px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
/* ===== Scrollbar ===== */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
/* ===== Utils ===== */
.flex { display: flex; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.text-muted { color: var(--text-muted); font-size: 12px; }
.mt-1 { margin-top: 4px; }
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 14px; }
+60
View File
@@ -0,0 +1,60 @@
<template>
<form @submit.prevent="onSubmit">
<div class="form-group">
<label>Имя *</label>
<input v-model="form.name" class="form-control" placeholder="Фамилия Имя Отчество" required />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group">
<label>Email</label>
<input v-model="form.email" type="email" class="form-control" placeholder="email@example.com" />
</div>
<div class="form-group">
<label>Телефон</label>
<input v-model="form.phone" class="form-control" placeholder="+7-900-000-0000" />
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group">
<label>Организация</label>
<input v-model="form.organization" class="form-control" placeholder="ООО Компания" />
</div>
<div class="form-group">
<label>Должность</label>
<input v-model="form.position" class="form-control" placeholder="Директор" />
</div>
</div>
<div class="form-group">
<label>Заметки</label>
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</template>
<script setup>
import { reactive, watch } from 'vue'
const props = defineProps({ initial: { type: Object, default: () => ({}) } })
const emit = defineEmits(['submit', 'cancel'])
const form = reactive({
name: props.initial.name || '',
email: props.initial.email || '',
phone: props.initial.phone || '',
organization: props.initial.organization || '',
position: props.initial.position || '',
notes: props.initial.notes || '',
})
watch(() => props.initial, (v) => {
if (v) Object.assign(form, v)
})
function onSubmit() {
emit('submit', { ...form })
}
</script>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+35
View File
@@ -0,0 +1,35 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/graph',
},
{
path: '/graph',
name: 'Graph',
component: () => import('../views/GraphView.vue'),
},
{
path: '/contacts',
name: 'Contacts',
component: () => import('../views/ContactsView.vue'),
},
{
path: '/contacts/:id',
name: 'ContactDetail',
component: () => import('../views/ContactDetailView.vue'),
},
{
path: '/import',
name: 'Import',
component: () => import('../views/ImportView.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
+67
View File
@@ -0,0 +1,67 @@
import { defineStore } from 'pinia'
import api from '../api'
export const useContactsStore = defineStore('contacts', {
state: () => ({
contacts: [],
relations: [],
loading: false,
error: null,
}),
getters: {
contactById: (state) => (id) => state.contacts.find((c) => c.id === id),
totalContacts: (state) => state.contacts.length,
totalRelations: (state) => state.relations.length,
},
actions: {
async fetchContacts(search = '') {
this.loading = true
this.error = null
try {
const params = search ? { search } : {}
const { data } = await api.get('/contacts/', { params })
this.contacts = data.results ?? data
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
async fetchRelations() {
const { data } = await api.get('/relations/')
this.relations = data.results ?? data
},
async createContact(payload) {
const { data } = await api.post('/contacts/', payload)
this.contacts.push(data)
return data
},
async updateContact(id, payload) {
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) {
await api.delete(`/contacts/${id}/`)
this.contacts = this.contacts.filter((c) => c.id !== id)
},
async createRelation(payload) {
const { data } = await api.post('/relations/', payload)
this.relations.push(data)
return data
},
async deleteRelation(id) {
await api.delete(`/relations/${id}/`)
this.relations = this.relations.filter((r) => r.id !== id)
},
},
})
+181
View File
@@ -0,0 +1,181 @@
<template>
<div>
<div class="page-header">
<div class="flex items-center gap-3">
<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>
<div class="page-content" v-if="contact">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px;">
<!-- Info card -->
<div class="card">
<h3 style="font-size:14px;margin-bottom:16px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;">Информация</h3>
<div class="form-group">
<label>Email</label>
<div>{{ contact.email || '—' }}</div>
</div>
<div class="form-group">
<label>Телефон</label>
<div>{{ contact.phone || '—' }}</div>
</div>
<div class="form-group">
<label>Организация</label>
<div>{{ contact.organization || '—' }}</div>
</div>
<div class="form-group">
<label>Должность</label>
<div>{{ contact.position || '—' }}</div>
</div>
<div class="form-group">
<label>Заметки</label>
<div style="white-space:pre-wrap;">{{ contact.notes || '—' }}</div>
</div>
</div>
<!-- Relations card -->
<div class="card">
<div class="flex justify-between items-center" style="margin-bottom:16px;">
<h3 style="font-size:14px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;">Связи ({{ contactRelations.length }})</h3>
<button class="btn btn-primary btn-sm" @click="showAddRelation = true">+ Добавить</button>
</div>
<div v-if="contactRelations.length === 0" class="empty-state" style="padding:20px 0;">
<p>Нет связей с другими контактами.</p>
</div>
<div v-else>
<div
v-for="rel in contactRelations"
:key="rel.id"
class="flex justify-between items-center"
style="padding:8px 0; border-bottom:1px solid var(--border);"
>
<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>
<div class="text-muted mt-1">{{ rel.description }}</div>
</div>
<button class="btn btn-danger btn-sm" @click="removeRelation(rel.id)"></button>
</div>
</div>
</div>
</div>
</div>
<!-- Edit modal -->
<div v-if="editing" class="modal-overlay" @click.self="editing = false">
<div class="modal">
<div class="modal-header">
<h3>Редактировать контакт</h3>
<button class="btn btn-secondary btn-sm" @click="editing = false"></button>
</div>
<ContactForm :initial="contact" @submit="onUpdate" @cancel="editing = false" />
</div>
</div>
<!-- Add relation modal -->
<div v-if="showAddRelation" class="modal-overlay" @click.self="showAddRelation = false">
<div class="modal">
<div class="modal-header">
<h3>Добавить связь</h3>
<button class="btn btn-secondary btn-sm" @click="showAddRelation = false"></button>
</div>
<div v-if="relError" class="alert alert-error">{{ relError }}</div>
<div class="form-group">
<label>С кем связать</label>
<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>
</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>
</div>
<div class="form-group">
<label>Описание (необязательно)</label>
<input v-model="newRel.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showAddRelation = false">Отмена</button>
<button class="btn btn-primary" :disabled="!newRel.targetId" @click="addRelation">Создать связь</button>
</div>
</div>
</div>
</div>
</template>
<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'
const route = useRoute()
const store = useContactsStore()
const contact = ref(null)
const editing = ref(false)
const showAddRelation = ref(false)
const relationTypes = ref([])
const relError = ref('')
const newRel = ref({ targetId: '', type: 'acquaintance', description: '' })
const contactId = computed(() => Number(route.params.id))
const contactRelations = computed(() =>
store.relations.filter(
(r) => r.source === contactId.value || r.target === contactId.value
)
)
const otherContacts = computed(() =>
store.contacts.filter((c) => c.id !== contactId.value)
)
function relLabel(type) {
return relationTypes.value.find((r) => r.value === type)?.label || type
}
async function loadContact() {
const { data } = await api.get(`/contacts/${contactId.value}/`)
contact.value = data
}
async function onUpdate(data) {
await store.updateContact(contactId.value, data)
contact.value = { ...contact.value, ...data }
editing.value = false
}
async function addRelation() {
relError.value = ''
try {
await store.createRelation({
source: contactId.value,
target: newRel.value.targetId,
relation_type: newRel.value.type,
description: newRel.value.description,
})
showAddRelation.value = false
newRel.value = { targetId: '', type: 'acquaintance', description: '' }
} catch (e) {
const msg = e.response?.data
relError.value = typeof msg === 'object' ? JSON.stringify(msg) : String(msg)
}
}
async function removeRelation(id) {
await store.deleteRelation(id)
}
onMounted(async () => {
await loadContact()
await Promise.all([store.fetchContacts(), store.fetchRelations()])
const { data } = await api.get('/relation-types/')
relationTypes.value = data
})
</script>
+124
View File
@@ -0,0 +1,124 @@
<template>
<div>
<div class="page-header">
<h2>Контакты</h2>
<button class="btn btn-primary" @click="showCreate = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Добавить
</button>
</div>
<div class="page-content">
<!-- Search -->
<div class="search-bar">
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="search"
class="form-control"
placeholder="Поиск по имени..."
@input="onSearch"
/>
</div>
<div v-if="store.loading" class="spinner"></div>
<div v-else-if="store.contacts.length === 0" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
</svg>
<p>Нет контактов. Добавьте первый или импортируйте файл.</p>
</div>
<div v-else class="card" style="padding:0; overflow:hidden;">
<table class="table">
<thead>
<tr>
<th>Имя</th>
<th>Организация</th>
<th>Email</th>
<th>Связей</th>
<th style="width:80px;"></th>
</tr>
</thead>
<tbody>
<tr v-for="c in store.contacts" :key="c.id" @click="goTo(c.id)">
<td>
<div style="font-weight:500;">{{ c.name }}</div>
<div class="text-muted mt-1">{{ c.position }}</div>
</td>
<td>{{ c.organization || '—' }}</td>
<td>{{ c.email || '—' }}</td>
<td>
<span class="badge badge-colleague">{{ c.relations_count }}</span>
</td>
<td @click.stop>
<button class="btn btn-danger btn-sm" @click="confirmDelete(c)">Удалить</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create modal -->
<div v-if="showCreate" class="modal-overlay" @click.self="showCreate = false">
<div class="modal">
<div class="modal-header">
<h3>Новый контакт</h3>
<button class="btn btn-secondary btn-sm" @click="showCreate = false"></button>
</div>
<ContactForm :initial="{}" @submit="onCreate" @cancel="showCreate = false" />
</div>
</div>
<!-- Delete confirm -->
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
<div class="modal">
<div class="modal-header">
<h3>Удалить контакт?</h3>
</div>
<p class="text-muted">Будет удалён контакт <strong style="color:var(--text)">{{ deleteTarget.name }}</strong> и все его связи.</p>
<div class="modal-footer">
<button class="btn btn-secondary" @click="deleteTarget = null">Отмена</button>
<button class="btn btn-danger" @click="doDelete">Удалить</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useContactsStore } from '../stores/contacts'
import ContactForm from '../components/ContactForm.vue'
const store = useContactsStore()
const router = useRouter()
const search = ref('')
const showCreate = ref(false)
const deleteTarget = ref(null)
let searchTimer = null
function onSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => store.fetchContacts(search.value), 300)
}
function goTo(id) { router.push(`/contacts/${id}`) }
async function onCreate(data) {
await store.createContact(data)
showCreate.value = false
}
function confirmDelete(c) { deleteTarget.value = c }
async function doDelete() {
await store.deleteContact(deleteTarget.value.id)
deleteTarget.value = null
}
</script>
+323
View File
@@ -0,0 +1,323 @@
<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>
<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>
</div>
<div class="graph-area">
<div v-if="loading" class="spinner"></div>
<div v-else-if="nodes.length === 0" class="empty-state card">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="5" cy="12" r="3"/><circle cx="19" cy="5" r="3"/><circle cx="19" cy="19" r="3"/>
</svg>
<p>Нет контактов. <RouterLink to="/contacts">Добавьте контакты</RouterLink> или <RouterLink to="/import">импортируйте файл</RouterLink>.</p>
</div>
<div v-else id="graph-container" ref="graphContainer"></div>
</div>
<!-- Node detail panel -->
<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>Email</label>
<div>{{ selectedContact.email || '—' }}</div>
</div>
<div class="form-group">
<label>Телефон</label>
<div>{{ selectedContact.phone || '—' }}</div>
</div>
<div class="form-group">
<label>Организация / Должность</label>
<div>{{ [selectedContact.organization, selectedContact.position].filter(Boolean).join(' · ') || '—' }}</div>
</div>
<div class="form-group">
<label>Заметки</label>
<div>{{ selectedContact.notes || '—' }}</div>
</div>
<div class="form-group">
<label>Связей</label>
<div>{{ selectedContact.relations_count }}</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 api from '../api'
import { useContactsStore } from '../stores/contacts'
const store = useContactsStore()
const graphContainer = ref(null)
const loading = ref(true)
const network = ref(null)
const physicsEnabled = ref(true)
const selectedNode = ref(null)
let nodesDS = null
let edgesDS = null
let initRetryCount = 0
const INIT_RETRY_MAX = 40
let initRetryTimer = null
let resizeObserver = null
const nodes = ref([])
const edges = ref([])
const allRelationTypes = ref([])
const activeFilters = ref([])
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' },
}
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)
} finally {
loading.value = false
}
// Контейнер #graph-container в DOM только когда loading=false и nodes.length > 0
if (nodes.value.length > 0) {
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
initNetwork()
}
}
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 initNetwork() {
if (!graphContainer.value) return
const el = graphContainer.value
let width = el.offsetWidth
let height = el.offsetHeight
if (width < 10 || height < 10) {
if (initRetryCount >= INIT_RETRY_MAX) {
el.style.height = '400px'
el.style.width = '100%'
width = el.offsetWidth
height = el.offsetHeight
} else {
initRetryCount += 1
initRetryTimer = setTimeout(initNetwork, 80)
return
}
}
initRetryCount = 0
if (initRetryTimer) {
clearTimeout(initRetryTimer)
initRetryTimer = null
}
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 },
shape: 'dot',
size: 14,
}))
const edgeList = filteredEdges().map((e) => ({
id: String(e.id),
from: String(e.from),
to: String(e.to),
label: e.label,
title: e.title,
relation_type: e.relation_type,
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
font: { color: '#7b82a6', size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'curvedCW', roundness: 0.1 },
}))
nodesDS = new DataSet(nodeList)
edgesDS = new DataSet(edgeList)
network.value = new Network(
el,
{ nodes: nodesDS, edges: edgesDS },
{
physics: {
enabled: true,
stabilization: { iterations: 150 },
barnesHut: { gravitationalConstant: -3000, springLength: 180 },
},
interaction: {
tooltipDelay: 200,
hover: true,
hideEdgesOnDrag: true,
zoomView: true,
},
nodes: { borderWidth: 1.5 },
}
)
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.once('stabilizationIterationsDone', () => {
network.value?.fit({ animation: { duration: 400 } })
setTimeout(() => {
network.value?.fit({ animation: false })
network.value?.redraw()
}, 100)
})
setTimeout(() => {
if (network.value) {
network.value.fit({ animation: false })
network.value.redraw()
}
}, 800)
resizeObserver = new ResizeObserver(() => {
network.value?.redraw()
})
resizeObserver.observe(el)
}
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)
}
if (edgesDS) {
edgesDS.clear()
edgesDS.add(
filteredEdges().map((e) => ({
...e,
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
font: { color: '#7b82a6', size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'curvedCW', roundness: 0.1 },
}))
)
}
}
function resetView() {
network.value?.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } })
}
function togglePhysics() {
physicsEnabled.value = !physicsEnabled.value
network.value?.setOptions({ physics: { enabled: physicsEnabled.value } })
}
onMounted(loadGraph)
onUnmounted(() => {
if (initRetryTimer) clearTimeout(initRetryTimer)
resizeObserver?.disconnect()
network.value?.destroy()
})
</script>
<style scoped>
.graph-view {
display: flex;
flex-direction: column;
flex: 1;
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;
}
.graph-area {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 28px 20px;
}
#graph-container {
flex: 1;
min-height: 300px;
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
</style>
+137
View File
@@ -0,0 +1,137 @@
<template>
<div>
<div class="page-header">
<h2>Импорт контактов</h2>
</div>
<div class="page-content" style="max-width:640px;">
<div class="card">
<h3 style="font-size:14px;margin-bottom:6px;">Загрузить файл</h3>
<p class="text-muted" style="margin-bottom:18px;">
Поддерживаются форматы <strong style="color:var(--text)">CSV</strong> и <strong style="color:var(--text)">JSON</strong>.
</p>
<!-- Format examples -->
<div class="card" style="background:var(--surface-alt);margin-bottom:18px;padding:14px;">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px;">Пример CSV:</div>
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">name,email,phone,organization,position,notes
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример JSON:</div>
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">[{"name":"Иван Иванов","email":"ivan@example.com","organization":"ООО Ромашка"}]</pre>
</div>
<!-- Drop zone -->
<div
class="drop-zone"
:class="{ 'drag-over': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="onDrop"
@click="$refs.fileInput.click()"
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.4;margin:0 auto 10px;display:block;">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div style="font-size:13px;color:var(--text-muted);">
{{ selectedFile ? selectedFile.name : 'Перетащите файл или нажмите для выбора' }}
</div>
<input ref="fileInput" type="file" accept=".csv,.json" style="display:none" @change="onFileSelect" />
</div>
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:16px;">
<span v-if="result.error">{{ result.error }}</span>
<span v-else>
В файле: <strong>{{ result.total ?? result.created + result.skipped }}</strong>,
импортировано: <strong>{{ result.created }}</strong>,
пропущено: {{ result.skipped }}.
<span v-if="result.errors?.length"> Ошибок: {{ result.errors.length }}.</span>
</span>
</div>
<div v-if="result?.errors?.length" style="margin-top:8px;">
<div v-for="e in result.errors" :key="e" class="text-muted" style="font-size:12px;">{{ e }}</div>
</div>
<div style="margin-top:16px;">
<button
class="btn btn-primary"
:disabled="!selectedFile || importing"
@click="doImport"
>
<svg v-if="importing" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin .7s linear infinite;">
<path d="M21 12a9 9 0 1 1-6.22-8.56"/>
</svg>
{{ importing ? 'Импорт...' : 'Импортировать' }}
</button>
<button v-if="selectedFile" class="btn btn-secondary" style="margin-left:8px;" @click="reset">Сбросить</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import api from '../api'
import { useContactsStore } from '../stores/contacts'
const store = useContactsStore()
const fileInput = ref(null)
const selectedFile = ref(null)
const isDragging = ref(false)
const importing = ref(false)
const result = ref(null)
function onFileSelect(e) {
selectedFile.value = e.target.files[0] || null
result.value = null
}
function onDrop(e) {
isDragging.value = false
const file = e.dataTransfer.files[0]
if (file) { selectedFile.value = file; result.value = null }
}
async function doImport() {
if (!selectedFile.value) return
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()
} catch (e) {
result.value = { error: e.response?.data?.error || e.message }
} finally {
importing.value = false
}
}
function reset() {
selectedFile.value = null
result.value = null
if (fileInput.value) fileInput.value.value = ''
}
</script>
<style scoped>
.drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 36px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.drop-zone:hover, .drag-over {
border-color: var(--accent);
background: var(--accent-dim);
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
optimizeDeps: {
esbuildOptions: {
sourcemap: false,
},
},
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
},
},
})
+9
View File
@@ -0,0 +1,9 @@
name,email,phone,organization,position,notes
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Генеральный директор,Познакомились на конференции 2023
Мария Петрова,maria@company.ru,+7-900-000-0002,Газпром,Аналитик данных,Коллега по проекту X
Алексей Смирнов,alex.smirnov@mail.ru,+7-921-000-0003,Яндекс,Разработчик,
Ольга Кузнецова,olga.k@tech.ru,+7-931-000-0004,Сбербанк,Менеджер проектов,Знакомы через Марию
Дмитрий Волков,d.volkov@startup.io,+7-950-000-0005,ИП Волков,Предприниматель,Потенциальный партнёр
Анна Соколова,anna.s@design.ru,,Freelance,UI/UX Designer,Дизайнер сайта
Сергей Новиков,s.novikov@gov.ru,+7-812-000-0006,Администрация СПб,Советник,
Екатерина Морозова,katya@fintech.ru,+7-962-000-0007,ФинТех,Финансовый директор,Знакомы с 2019
1 name email phone organization position notes
2 Иван Иванов ivan@example.com +7-900-000-0001 ООО Ромашка Генеральный директор Познакомились на конференции 2023
3 Мария Петрова maria@company.ru +7-900-000-0002 Газпром Аналитик данных Коллега по проекту X
4 Алексей Смирнов alex.smirnov@mail.ru +7-921-000-0003 Яндекс Разработчик
5 Ольга Кузнецова olga.k@tech.ru +7-931-000-0004 Сбербанк Менеджер проектов Знакомы через Марию
6 Дмитрий Волков d.volkov@startup.io +7-950-000-0005 ИП Волков Предприниматель Потенциальный партнёр
7 Анна Соколова anna.s@design.ru Freelance UI/UX Designer Дизайнер сайта
8 Сергей Новиков s.novikov@gov.ru +7-812-000-0006 Администрация СПб Советник
9 Екатерина Морозова katya@fintech.ru +7-962-000-0007 ФинТех Финансовый директор Знакомы с 2019
+7
View File
@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}