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
+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,
},
},
},
})