WIP: local changes
This commit is contained in:
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user