Add multiple network maps, graph UX improvements, and full backup import.

Support per-map contact membership with scoped graph views, relation editing on edges, layout caching, and automatic detection of full JSON backups so contacts and relations import together.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 17:54:51 +03:00
co-authored by Cursor
parent 35ec4c81ec
commit 5155b3a37a
50 changed files with 3176 additions and 372 deletions
+92 -51
View File
@@ -5,16 +5,16 @@
<button class="btn btn-secondary btn-sm" @click="$router.back()"> Назад</button>
<h2>{{ contact?.name || 'Загрузка...' }}</h2>
</div>
<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">
<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="card-section-header">
<h3 class="card-section-title">Информация</h3>
<button class="btn btn-primary btn-sm" type="button" @click="editing = true">Редактировать</button>
</div>
<div class="form-group">
<label>Email</label>
<div>{{ contact.email || '—' }}</div>
@@ -36,15 +36,18 @@
<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
<label>Карты сети</label>
<div v-if="contactMapNames.length">
<RouterLink
v-for="name in contactMapNames"
:key="name.id"
:to="`/map/${name.id}`"
class="map-link"
>
{{ name.label }}
</RouterLink>
</div>
</div>
<div class="form-group">
<label>Карта сети</label>
<div>{{ contact.include_on_network_map ? 'Показывается на карте' : 'Не на карте (только в общем графе)' }}</div>
<div v-else>Не участвует ни на одной карте (только в общем графе)</div>
</div>
</div>
@@ -57,26 +60,40 @@
<div v-if="contactRelations.length === 0" class="empty-state" style="padding:20px 0;">
<p>Нет связей с другими контактами.</p>
</div>
<div v-else>
<div v-else class="relations-list">
<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);"
class="relation-row"
:class="{ 'is-selected': String(editRelationTarget?.id) === String(rel.id) }"
@click="openEditRelation(rel)"
>
<div>
<span style="font-weight:500;">{{ rel.source === contact.id ? rel.target_name : rel.source_name }}</span>
<div class="relation-row__body">
<span style="font-weight:500;">{{ otherContactName(rel) }}</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 v-if="rel.description" class="text-muted mt-1">{{ rel.description }}</div>
</div>
<div class="relation-row__actions" @click.stop>
<button class="btn btn-secondary btn-sm" type="button" @click="openEditRelation(rel)">
Изменить
</button>
<button class="btn btn-danger btn-sm" type="button" @click="removeRelation(rel.id)"></button>
</div>
<button class="btn btn-danger btn-sm" @click="removeRelation(rel.id)"></button>
</div>
</div>
</div>
</div>
</div>
<EditRelationModal
:open="editRelationOpen"
:relation="editRelationTarget"
@close="closeEditRelation"
@updated="onRelationUpdated"
@deleted="onRelationDeleted"
/>
<!-- Edit modal -->
<div v-if="editing" class="modal-overlay" @click.self="editing = false">
<div class="modal">
@@ -84,7 +101,13 @@
<h3>Редактировать контакт</h3>
<button class="btn btn-secondary btn-sm" @click="editing = false"></button>
</div>
<ContactForm :initial="contact" @submit="onUpdate" @cancel="editing = false" />
<ContactForm
:initial="contact"
deletable
@submit="onUpdate"
@cancel="editing = false"
@delete="confirmDeleteContact"
/>
</div>
</div>
@@ -106,19 +129,11 @@
</div>
<div class="form-group">
<label>Тип связи</label>
<SearchableSelect
v-model="newRel.type"
:options="relationTypes"
placeholder="Тип связи..."
/>
<RelationTypeSelect v-model="newRel.type" :options="relationTypes" />
</div>
<div class="form-group">
<label>Интенсивность общения</label>
<SearchableSelect
v-model="newRel.interaction_intensity"
:options="interactionIntensities"
placeholder="Интенсивность..."
/>
<InteractionIntensitySelect v-model="newRel.interaction_intensity" />
</div>
<div class="form-group">
<label>Описание (необязательно)</label>
@@ -138,19 +153,24 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useContactsStore } from '../stores/contacts'
import ContactForm from '../components/ContactForm.vue'
import EditRelationModal from '../components/EditRelationModal.vue'
import SearchableSelect from '../components/SearchableSelect.vue'
import InteractionIntensitySelect from '../components/InteractionIntensitySelect.vue'
import RelationTypeSelect from '../components/RelationTypeSelect.vue'
const route = useRoute()
const router = useRouter()
const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const contact = ref(null)
const editing = ref(false)
const showAddRelation = ref(false)
const editRelationOpen = ref(false)
const editRelationTarget = ref(null)
const relationTypes = ref([])
const lifeSpheres = ref([])
const networkCircles = ref([])
const interactionIntensities = ref([])
const relError = ref('')
const newRel = ref({
@@ -179,30 +199,56 @@ const contactSelectOptions = computed(() =>
otherContacts.value.map((c) => ({ value: c.id, label: c.name }))
)
const contactMapNames = computed(() => {
const memberships = mapsStore.contactMemberships || []
return memberships.map((m) => ({
id: m.mapId || m.map,
label: m.map_name || mapsStore.maps.find((map) => String(map.id) === String(m.mapId || m.map))?.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() {
contact.value = await store.fetchContactById(contactId.value)
function otherContactName(rel) {
const cid = String(contactId.value)
return String(rel.source) === cid ? rel.target_name : rel.source_name
}
async function onUpdate(data) {
function openEditRelation(rel) {
editRelationTarget.value = rel
editRelationOpen.value = true
}
function closeEditRelation() {
editRelationOpen.value = false
editRelationTarget.value = null
}
function onRelationUpdated() {
closeEditRelation()
}
function onRelationDeleted() {
closeEditRelation()
}
async function loadContact() {
contact.value = await store.fetchContactById(contactId.value)
await mapsStore.fetchContactMemberships(contactId.value)
}
async function onUpdate(data, mapIds) {
await store.updateContact(contactId.value, data)
await mapsStore.setContactMapMemberships(contactId.value, mapIds)
contact.value = { ...contact.value, ...data }
editing.value = false
await mapsStore.fetchContactMemberships(contactId.value)
}
async function addRelation() {
@@ -233,15 +279,10 @@ async function removeRelation(id) {
}
onMounted(async () => {
await mapsStore.fetchMaps()
await loadContact()
await Promise.all([store.fetchContacts(), store.fetchRelations()])
const [rt, mapChoices] = await Promise.all([
store.fetchRelationTypes(),
store.fetchNetworkMapChoices(),
])
const rt = await store.fetchRelationTypes()
relationTypes.value = rt
lifeSpheres.value = mapChoices.life_spheres
networkCircles.value = mapChoices.network_circles
interactionIntensities.value = mapChoices.interaction_intensities
})
</script>