Add network map UX and theme support.

Introduce network map view/components with filtering and positioning, expand contacts/map backend fields and migrations, and add a persistent light/dark theme toggle with graph label color updates for readability.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 13:24:48 +03:00
co-authored by Cursor
parent 81ba6cd076
commit b668ee8507
49 changed files with 4679 additions and 129 deletions
+83 -17
View File
@@ -1,11 +1,13 @@
<template>
<div>
<div class="page-header">
<div class="flex items-center gap-3">
<div class="page-header__title">
<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 v-if="contact" class="page-header__actions">
<button class="btn btn-primary btn-sm" @click="editing = true">Редактировать</button>
</div>
</div>
<div class="page-content" v-if="contact">
@@ -33,6 +35,17 @@
<label>Заметки</label>
<div style="white-space:pre-wrap;">{{ contact.notes || '—' }}</div>
</div>
<div class="form-group">
<label>Сфера жизни / круг / важность</label>
<div>
{{ sphereLabel(contact.life_sphere) }} · {{ circleLabel(contact.network_circle) }}
· важность {{ contact.importance ?? '—' }}/5
</div>
</div>
<div class="form-group">
<label>Карта сети</label>
<div>{{ contact.include_on_network_map ? 'Показывается на карте' : 'Не на карте (только в общем графе)' }}</div>
</div>
</div>
<!-- Relations card -->
@@ -54,6 +67,7 @@
<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>
<span class="text-muted" style="margin-left:8px;font-size:12px;">{{ intensityLabel(rel.interaction_intensity) }}</span>
<div class="text-muted mt-1">{{ rel.description }}</div>
</div>
<button class="btn btn-danger btn-sm" @click="removeRelation(rel.id)"></button>
@@ -84,21 +98,35 @@
<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>
<SearchableSelect
v-model="newRel.targetId"
:options="contactSelectOptions"
placeholder="Введите имя контакта..."
/>
</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>
<SearchableSelect
v-model="newRel.type"
:options="relationTypes"
placeholder="Тип связи..."
/>
</div>
<div class="form-group">
<label>Интенсивность общения</label>
<SearchableSelect
v-model="newRel.interaction_intensity"
:options="interactionIntensities"
placeholder="Интенсивность..."
/>
</div>
<div class="form-group">
<label>Описание (необязательно)</label>
<input v-model="newRel.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
</div>
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">
Стрелка на карте сети идёт от вас к выбранному контакту: вы указаны как источник связи.
</p>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showAddRelation = false">Отмена</button>
<button class="btn btn-primary" :disabled="!newRel.targetId" @click="addRelation">Создать связь</button>
@@ -111,9 +139,9 @@
<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'
import SearchableSelect from '../components/SearchableSelect.vue'
const route = useRoute()
const store = useContactsStore()
@@ -121,8 +149,16 @@ const contact = ref(null)
const editing = ref(false)
const showAddRelation = ref(false)
const relationTypes = ref([])
const lifeSpheres = ref([])
const networkCircles = ref([])
const interactionIntensities = ref([])
const relError = ref('')
const newRel = ref({ targetId: '', type: 'acquaintance', description: '' })
const newRel = ref({
targetId: '',
type: 'acquaintance',
description: '',
interaction_intensity: 'intense',
})
const contactId = computed(() => Number(route.params.id))
@@ -133,16 +169,34 @@ const contactRelations = computed(() =>
)
const otherContacts = computed(() =>
store.contacts.filter((c) => c.id !== contactId.value)
store.contacts
.filter((c) => c.id !== contactId.value)
.slice()
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
)
const contactSelectOptions = computed(() =>
otherContacts.value.map((c) => ({ value: c.id, label: c.name }))
)
function relLabel(type) {
return relationTypes.value.find((r) => r.value === type)?.label || type
}
function sphereLabel(v) {
return lifeSpheres.value.find((x) => x.value === v)?.label || v || '—'
}
function circleLabel(v) {
return networkCircles.value.find((x) => x.value === v)?.label || v || '—'
}
function intensityLabel(v) {
return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
}
async function loadContact() {
const { data } = await api.get(`/contacts/${contactId.value}/`)
contact.value = data
contact.value = await store.fetchContactById(contactId.value)
}
async function onUpdate(data) {
@@ -159,9 +213,15 @@ async function addRelation() {
target: newRel.value.targetId,
relation_type: newRel.value.type,
description: newRel.value.description,
interaction_intensity: newRel.value.interaction_intensity,
})
showAddRelation.value = false
newRel.value = { targetId: '', type: 'acquaintance', description: '' }
newRel.value = {
targetId: '',
type: 'acquaintance',
description: '',
interaction_intensity: 'intense',
}
} catch (e) {
const msg = e.response?.data
relError.value = typeof msg === 'object' ? JSON.stringify(msg) : String(msg)
@@ -175,7 +235,13 @@ async function removeRelation(id) {
onMounted(async () => {
await loadContact()
await Promise.all([store.fetchContacts(), store.fetchRelations()])
const { data } = await api.get('/relation-types/')
relationTypes.value = data
const [rt, mapChoices] = await Promise.all([
store.fetchRelationTypes(),
store.fetchNetworkMapChoices(),
])
relationTypes.value = rt
lifeSpheres.value = mapChoices.life_spheres
networkCircles.value = mapChoices.network_circles
interactionIntensities.value = mapChoices.interaction_intensities
})
</script>