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
+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>