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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user