WIP: local changes
This commit is contained in:
@@ -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)
|
||||||
|
- [ ] Уведомления / дни рождения
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
db.sqlite3
|
||||||
|
.env
|
||||||
@@ -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"]
|
||||||
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,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'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('api/', include('contacts.urls')),
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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})'
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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.
@@ -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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Django==4.2.9
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
django-cors-headers==4.3.1
|
||||||
|
Pillow==10.2.0
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user