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
@@ -0,0 +1,72 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h3>Добавить на карту</h3>
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')"></button>
</div>
<div class="form-group">
<label>Контакт</label>
<SearchableSelect
v-model="selectedContactId"
:options="contactOptions"
placeholder="Выберите контакт..."
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedContactId"
@click="onAdd"
>
Добавить
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import SearchableSelect from './SearchableSelect.vue'
const props = defineProps({
open: { type: Boolean, default: false },
contacts: { type: Array, default: () => [] },
memberContactIds: { type: Array, default: () => [] },
})
const emit = defineEmits(['close', 'add'])
const selectedContactId = ref('')
const memberSet = computed(() => new Set(props.memberContactIds.map(String)))
const contactOptions = computed(() =>
props.contacts
.filter((c) => !memberSet.value.has(String(c.id)))
.map((c) => ({ value: String(c.id), label: c.name }))
)
watch(
() => props.open,
(isOpen) => {
if (isOpen) selectedContactId.value = ''
}
)
function onAdd() {
if (!selectedContactId.value) return
emit('add', selectedContactId.value)
}
</script>
<style scoped>
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
</style>
+29 -15
View File
@@ -1,25 +1,39 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ContactForm from './ContactForm.vue'
vi.mock('../api', () => ({
default: {
get: vi.fn(async () => ({
data: {
life_spheres: [{ value: 'work', label: 'Работа' }],
network_circles: [{ value: 'support', label: 'Круг поддержки' }],
},
})),
},
vi.mock('../stores/networkMaps', () => ({
useNetworkMapsStore: () => ({
maps: [{ id: 'map-1', name: 'Основная карта' }],
fetchMaps: vi.fn(async () => []),
}),
}))
vi.mock('../application/usecases/networkMaps', () => ({
listMembershipsByContact: vi.fn(async () => []),
}))
describe('ContactForm', () => {
it('emits submit with normalized payload', async () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('emits submit with contact data and map ids', async () => {
const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } })
await wrapper.get('form').trigger('submit.prevent')
const payload = wrapper.emitted('submit')[0][0]
expect(payload.name).toBe('Иван')
expect(payload.importance).toBeDefined()
expect(payload.include_on_network_map).toBe(false)
const [contactData, mapIds] = wrapper.emitted('submit')[0]
expect(contactData.name).toBe('Иван')
expect(contactData.include_on_network_map).toBeUndefined()
expect(mapIds).toEqual([])
})
it('shows delete button when editing existing contact', async () => {
const wrapper = mount(ContactForm, {
props: { initial: { id: '1', name: 'Иван' }, deletable: true },
})
const deleteBtn = wrapper.find('.btn-danger')
expect(deleteBtn.exists()).toBe(true)
await deleteBtn.trigger('click')
expect(wrapper.emitted('delete')).toHaveLength(1)
})
})
+82 -77
View File
@@ -24,70 +24,65 @@
<input v-model="form.position" class="form-control" placeholder="Директор" />
</div>
</div>
<div class="contact-form-map-fields">
<div class="form-group">
<label>Сфера жизни</label>
<SearchableSelect
v-model="form.life_sphere"
:options="lifeSpheres"
placeholder="Сфера жизни..."
/>
<div class="form-group">
<label>Карты сети</label>
<div v-if="!availableMaps.length" class="text-muted" style="font-size:13px;">
Нет карт. Создайте карту на странице «Карта сети».
</div>
<div class="form-group">
<label>Круг сети</label>
<SearchableSelect
v-model="form.network_circle"
:options="networkCircles"
placeholder="Круг сети..."
/>
<div v-else class="map-checkboxes">
<label v-for="map in availableMaps" :key="map.id" class="checkbox-label">
<input
v-model="form.mapIds"
type="checkbox"
:value="String(map.id)"
/>
{{ map.name }}
</label>
</div>
<div class="form-group">
<label>Важность (15)</label>
<input v-model.number="form.importance" type="number" min="1" max="5" class="form-control" />
</div>
</div>
<div class="form-group checkbox-row">
<label class="checkbox-label">
<input v-model="form.include_on_network_map" type="checkbox" />
Показывать на карте сети
</label>
<p class="checkbox-hint">На странице «Граф» контакт виден всегда; на «Карте сети» только с этой отметкой.</p>
<p class="checkbox-hint">На странице «Граф» контакт виден всегда; на выбранных картах в соответствующих визуализациях.</p>
</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 class="contact-form-footer">
<button
v-if="showDelete"
type="button"
class="btn btn-danger"
@click="$emit('delete')"
>
Удалить
</button>
<div class="contact-form-actions">
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</div>
</form>
</template>
<script setup>
import { reactive, watch, ref, onMounted } from 'vue'
import api from '../api'
import SearchableSelect from './SearchableSelect.vue'
import { reactive, watch, computed, onMounted } from 'vue'
import { useNetworkMapsStore } from '../stores/networkMaps'
import { listMembershipsByContact } from '../application/usecases/networkMaps'
const FALLBACK_SPHERES = [
{ value: 'work', label: 'Работа' },
{ value: 'study', label: 'Учёба' },
{ value: 'hobby', label: 'Хобби' },
{ value: 'family', label: 'Семья' },
{ value: 'health', label: 'Здоровье' },
{ value: 'other', label: 'Другое' },
]
const FALLBACK_CIRCLES = [
{ value: 'support', label: 'Круг поддержки' },
{ value: 'productivity', label: 'Круг продуктивности' },
{ value: 'development', label: 'Круг развития' },
]
const props = defineProps({
initial: { type: Object, default: () => ({}) },
initialMapIds: { type: Array, default: null },
deletable: { type: Boolean, default: false },
})
const emit = defineEmits(['submit', 'cancel', 'delete'])
const props = defineProps({ initial: { type: Object, default: () => ({}) } })
const emit = defineEmits(['submit', 'cancel'])
const mapsStore = useNetworkMapsStore()
const lifeSpheres = ref([...FALLBACK_SPHERES])
const networkCircles = ref([...FALLBACK_CIRCLES])
const showDelete = computed(() => {
if (props.deletable) return true
const id = props.initial?.id
return id !== undefined && id !== null && id !== ''
})
const availableMaps = computed(() => mapsStore.maps)
const form = reactive({
name: props.initial.name || '',
@@ -96,13 +91,19 @@ const form = reactive({
organization: props.initial.organization || '',
position: props.initial.position || '',
notes: props.initial.notes || '',
life_sphere: props.initial.life_sphere || 'other',
network_circle: props.initial.network_circle || 'productivity',
importance: props.initial.importance ?? 3,
include_on_network_map: Boolean(props.initial.include_on_network_map),
mapIds: (props.initialMapIds || []).map(String),
})
watch(() => props.initial, (v) => {
async function loadMapIdsForContact(contactId) {
if (!contactId) {
form.mapIds = []
return
}
const memberships = await listMembershipsByContact(contactId)
form.mapIds = memberships.map((m) => String(m.mapId || m.map))
}
watch(() => props.initial, async (v) => {
if (!v || !Object.keys(v).length) {
Object.assign(form, {
name: '',
@@ -111,10 +112,7 @@ watch(() => props.initial, (v) => {
organization: '',
position: '',
notes: '',
life_sphere: 'other',
network_circle: 'productivity',
importance: 3,
include_on_network_map: false,
mapIds: [],
})
return
}
@@ -125,37 +123,32 @@ watch(() => props.initial, (v) => {
organization: v.organization || '',
position: v.position || '',
notes: v.notes || '',
life_sphere: v.life_sphere || 'other',
network_circle: v.network_circle || 'productivity',
importance: v.importance ?? 3,
include_on_network_map: Boolean(v.include_on_network_map),
})
if (props.initialMapIds) {
form.mapIds = props.initialMapIds.map(String)
} else if (v.id) {
await loadMapIdsForContact(v.id)
}
}, { deep: true })
onMounted(async () => {
try {
const { data } = await api.get('/network-map-choices/')
if (data.life_spheres?.length) lifeSpheres.value = data.life_spheres
if (data.network_circles?.length) networkCircles.value = data.network_circles
} catch {
/* оставляем FALLBACK_* */
await mapsStore.fetchMaps()
if (props.initial?.id && !props.initialMapIds) {
await loadMapIdsForContact(props.initial.id)
}
})
function onSubmit() {
emit('submit', { ...form })
const { mapIds, ...contactData } = form
emit('submit', contactData, mapIds)
}
</script>
<style scoped>
.contact-form-map-fields {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.checkbox-row {
margin-top: 4px;
.map-checkboxes {
display: flex;
flex-direction: column;
gap: 8px;
}
.checkbox-label {
display: flex;
@@ -170,4 +163,16 @@ function onSubmit() {
color: var(--text-muted);
line-height: 1.4;
}
.contact-form-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 22px;
}
.contact-form-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
</style>
@@ -20,19 +20,11 @@
<div class="form-group">
<label>Тип связи</label>
<SearchableSelect
v-model="form.type"
:options="relationTypes"
placeholder="Тип связи..."
/>
<RelationTypeSelect v-model="form.type" />
</div>
<div class="form-group">
<label>Интенсивность общения</label>
<SearchableSelect
v-model="form.intensity"
:options="interactionIntensities"
placeholder="Интенсивность..."
/>
<InteractionIntensitySelect v-model="form.intensity" />
</div>
<div class="form-group">
<label>Описание (необязательно)</label>
@@ -52,8 +44,8 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { useContactsStore } from '../stores/contacts'
import SearchableSelect from './SearchableSelect.vue'
import { RELATION_TYPES, INTERACTION_INTENSITIES } from '../domain/networkChoices'
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
import RelationTypeSelect from './RelationTypeSelect.vue'
const props = defineProps({
open: { type: Boolean, default: false },
@@ -68,9 +60,6 @@ const saving = ref(false)
const error = ref('')
const swapped = ref(false)
const relationTypes = RELATION_TYPES
const interactionIntensities = INTERACTION_INTENSITIES
const form = ref({
type: 'acquaintance',
intensity: 'intense',
@@ -0,0 +1,155 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="onCancel">
<div class="modal">
<div class="modal-header">
<h3>Редактировать связь</h3>
<button class="btn btn-secondary btn-sm" type="button" @click="onCancel"></button>
</div>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<p v-if="relation" class="text-muted relation-ends">
<strong style="color:var(--text)">{{ sourceName }}</strong>
<span class="relation-arrow"></span>
<strong style="color:var(--text)">{{ targetName }}</strong>
</p>
<div class="form-group">
<label>Тип связи</label>
<RelationTypeSelect v-model="form.type" />
</div>
<div class="form-group">
<label>Интенсивность общения</label>
<InteractionIntensitySelect v-model="form.intensity" />
</div>
<div class="form-group">
<label>Описание (необязательно)</label>
<input v-model="form.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
</div>
<div class="relation-edit-footer">
<button
type="button"
class="btn btn-danger"
:disabled="saving || deleting"
@click="onDelete"
>
{{ deleting ? 'Удаление...' : 'Удалить' }}
</button>
<div class="relation-edit-actions">
<button class="btn btn-secondary" type="button" :disabled="saving || deleting" @click="onCancel">
Отмена
</button>
<button class="btn btn-primary" type="button" :disabled="saving || deleting" @click="submit">
{{ saving ? 'Сохранение...' : 'Сохранить' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useContactsStore } from '../stores/contacts'
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
import RelationTypeSelect from './RelationTypeSelect.vue'
const props = defineProps({
open: { type: Boolean, default: false },
relation: { type: Object, default: null },
})
const emit = defineEmits(['close', 'updated', 'deleted'])
const store = useContactsStore()
const saving = ref(false)
const deleting = ref(false)
const error = ref('')
const form = ref({
type: 'acquaintance',
intensity: 'intense',
description: '',
})
const sourceName = computed(() => props.relation?.source_name || '—')
const targetName = computed(() => props.relation?.target_name || '—')
watch(
() => [props.open, props.relation],
() => {
if (!props.open || !props.relation) return
error.value = ''
form.value = {
type: props.relation.relation_type || 'acquaintance',
intensity: props.relation.interaction_intensity || 'intense',
description: props.relation.description || '',
}
},
{ immediate: true }
)
function onCancel() {
emit('close')
}
async function submit() {
if (!props.relation?.id) return
saving.value = true
error.value = ''
try {
const updated = await store.updateRelation(props.relation.id, {
relation_type: form.value.type,
description: form.value.description,
interaction_intensity: form.value.intensity,
})
emit('updated', updated)
emit('close')
} catch (e) {
error.value = e?.message || String(e)
} finally {
saving.value = false
}
}
async function onDelete() {
if (!props.relation?.id) return
const label = `${sourceName.value}${targetName.value}`
if (!window.confirm(`Удалить связь «${label}»?`)) return
deleting.value = true
error.value = ''
try {
await store.deleteRelation(props.relation.id)
emit('deleted', props.relation.id)
emit('close')
} catch (e) {
error.value = e?.message || String(e)
} finally {
deleting.value = false
}
}
</script>
<style scoped>
.relation-ends {
margin: 0 0 16px;
line-height: 1.5;
}
.relation-arrow {
margin: 0 8px;
color: var(--text-muted);
}
.relation-edit-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 22px;
}
.relation-edit-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
</style>
@@ -0,0 +1,115 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="graph-node-menu-overlay"
@click="close"
@contextmenu.prevent="close"
/>
<div
v-if="open && edge"
class="graph-node-menu"
:style="{ left: `${x}px`, top: `${y}px` }"
role="menu"
@click.stop
@contextmenu.prevent
>
<div class="graph-node-menu__title">{{ edgeTitle }}</div>
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onEdit">
Редактировать связь
</button>
</div>
</Teleport>
</template>
<script setup>
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { RELATION_TYPES } from '../domain/networkChoices'
const props = defineProps({
open: { type: Boolean, default: false },
edge: { type: Object, default: null },
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
})
const emit = defineEmits(['close', 'edit'])
const typeLabels = Object.fromEntries(RELATION_TYPES.map((r) => [r.value, r.label]))
const edgeTitle = computed(() => {
if (!props.edge) return 'Связь'
const type = typeLabels[props.edge.relation_type] || props.edge.relation_type || 'Связь'
return type
})
function close() {
emit('close')
}
function onEdit() {
emit('edit', props.edge)
close()
}
function onKeyDown(event) {
if (event.key === 'Escape' && props.open) close()
}
watch(() => props.open, (isOpen) => {
if (isOpen) window.addEventListener('keydown', onKeyDown)
else window.removeEventListener('keydown', onKeyDown)
})
onMounted(() => {
if (props.open) window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
})
</script>
<style scoped>
.graph-node-menu-overlay {
position: fixed;
inset: 0;
z-index: 900;
}
.graph-node-menu {
position: fixed;
z-index: 901;
min-width: 180px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 6px 0;
}
.graph-node-menu__title {
padding: 6px 14px 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-node-menu__item {
display: block;
width: 100%;
text-align: left;
padding: 8px 14px;
font-size: 13px;
color: var(--text);
background: transparent;
border: none;
cursor: pointer;
}
.graph-node-menu__item:hover {
background: var(--accent-dim);
}
</style>
@@ -0,0 +1,26 @@
<template>
<select
class="form-control"
:value="modelValue"
@change="onChange"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</template>
<script setup>
import { INTERACTION_INTENSITIES } from '../domain/networkChoices'
defineProps({
modelValue: { type: String, default: 'intense' },
options: { type: Array, default: () => INTERACTION_INTENSITIES },
})
const emit = defineEmits(['update:modelValue'])
function onChange(e) {
emit('update:modelValue', e.target.value)
}
</script>
@@ -0,0 +1,86 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h3>{{ isEdit ? 'Редактировать карту' : 'Новая карта сети' }}</h3>
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')"></button>
</div>
<form @submit.prevent="onSubmit">
<div class="form-group">
<label>Название *</label>
<input v-model="form.name" class="form-control" required placeholder="Например: Коллектив А" />
</div>
<div class="form-group">
<label>Описание</label>
<textarea
v-model="form.description"
class="form-control"
rows="3"
placeholder="Цель карты, контекст..."
/>
</div>
<div class="modal-footer">
<button
v-if="isEdit && deletable"
type="button"
class="btn btn-danger"
@click="$emit('delete')"
>
Удалить
</button>
<div class="modal-footer-actions">
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { reactive, watch, computed } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
initial: { type: Object, default: () => ({}) },
deletable: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'submit', 'delete'])
const isEdit = computed(() => Boolean(props.initial?.id))
const form = reactive({
name: '',
description: '',
})
watch(
() => [props.open, props.initial],
() => {
if (!props.open) return
form.name = props.initial?.name || ''
form.description = props.initial?.description || ''
},
{ immediate: true, deep: true }
)
function onSubmit() {
emit('submit', { name: form.name.trim(), description: form.description.trim() })
}
</script>
<style scoped>
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 20px;
}
.modal-footer-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
</style>
@@ -0,0 +1,56 @@
<template>
<div class="map-switcher">
<label class="map-switcher-label">Карта</label>
<select
class="form-control map-switcher-select"
:value="modelValue"
@change="onSelect"
>
<option v-for="map in maps" :key="map.id" :value="String(map.id)">
{{ map.name }}
</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" @click="$emit('create')">
+ Новая
</button>
<button
v-if="modelValue"
type="button"
class="btn btn-secondary btn-sm"
title="Настройки карты"
@click="$emit('manage', modelValue)"
>
</button>
</div>
</template>
<script setup>
defineProps({
maps: { type: Array, default: () => [] },
modelValue: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'create', 'manage'])
function onSelect(event) {
emit('update:modelValue', event.target.value)
}
</script>
<style scoped>
.map-switcher {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.map-switcher-label {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.map-switcher-select {
min-width: 160px;
max-width: 240px;
}
</style>
@@ -17,6 +17,7 @@
<p v-if="subtitle" class="network-map-sub">{{ subtitle }}</p>
</div>
<div class="network-map-actions">
<slot name="toolbar" />
<slot name="filters" />
<button class="btn btn-secondary btn-sm" @click="$emit('fit')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -0,0 +1,26 @@
<template>
<select
class="form-control"
:value="modelValue"
@change="onChange"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</template>
<script setup>
import { RELATION_TYPES } from '../domain/networkChoices'
defineProps({
modelValue: { type: String, default: 'acquaintance' },
options: { type: Array, default: () => RELATION_TYPES },
})
const emit = defineEmits(['update:modelValue'])
function onChange(e) {
emit('update:modelValue', e.target.value)
}
</script>