Add import/export, graph UX improvements, and contact management fixes.

Support vCard import and multi-format export, bulk delete with reliable local persistence, Ctrl+link relation creation, cluster-colored graph visualization, and right-click context menus for node details.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 11:36:13 +03:00
co-authored by Cursor
parent 2a32c61934
commit 35ec4c81ec
27 changed files with 1819 additions and 110 deletions
@@ -0,0 +1,142 @@
<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 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>
<button class="btn btn-secondary btn-sm swap-btn" type="button" @click="swapEnds">
Поменять направление
</button>
<div class="form-group">
<label>Тип связи</label>
<SearchableSelect
v-model="form.type"
:options="relationTypes"
placeholder="Тип связи..."
/>
</div>
<div class="form-group">
<label>Интенсивность общения</label>
<SearchableSelect
v-model="form.intensity"
:options="interactionIntensities"
placeholder="Интенсивность..."
/>
</div>
<div class="form-group">
<label>Описание (необязательно)</label>
<input v-model="form.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" @click="onCancel">Отмена</button>
<button class="btn btn-primary" type="button" :disabled="saving" @click="submit">
{{ saving ? 'Создание...' : 'Создать связь' }}
</button>
</div>
</div>
</div>
</template>
<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'
const props = defineProps({
open: { type: Boolean, default: false },
source: { type: Object, default: null },
target: { type: Object, default: null },
})
const emit = defineEmits(['close', 'created'])
const store = useContactsStore()
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',
description: '',
})
const sourceContact = computed(() => (swapped.value ? props.target : props.source))
const targetContact = computed(() => (swapped.value ? props.source : props.target))
const sourceName = computed(() => sourceContact.value?.name || '—')
const targetName = computed(() => targetContact.value?.name || '—')
watch(
() => props.open,
(isOpen) => {
if (!isOpen) return
swapped.value = false
error.value = ''
form.value = {
type: 'acquaintance',
intensity: 'intense',
description: '',
}
}
)
function swapEnds() {
swapped.value = !swapped.value
}
function onCancel() {
emit('close')
}
async function submit() {
if (!sourceContact.value?.id || !targetContact.value?.id) return
saving.value = true
error.value = ''
try {
const created = await store.createRelation({
source: sourceContact.value.id,
target: targetContact.value.id,
relation_type: form.value.type,
description: form.value.description,
interaction_intensity: form.value.intensity,
})
emit('created', created)
emit('close')
} catch (e) {
error.value = e?.message || String(e)
} finally {
saving.value = false
}
}
</script>
<style scoped>
.relation-ends {
margin: 0 0 10px;
line-height: 1.5;
}
.relation-arrow {
margin: 0 8px;
color: var(--text-muted);
}
.swap-btn {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,121 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="graph-node-menu-overlay"
@click="close"
@contextmenu.prevent="close"
/>
<div
v-if="open && node"
class="graph-node-menu"
:style="{ left: `${x}px`, top: `${y}px` }"
role="menu"
@click.stop
@contextmenu.prevent
>
<div class="graph-node-menu__title">{{ nodeLabel }}</div>
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onInfo">
Информация
</button>
<RouterLink
:to="`/contacts/${node.id}`"
class="graph-node-menu__item graph-node-menu__link"
role="menuitem"
@click="close"
>
Открыть карточку
</RouterLink>
</div>
</Teleport>
</template>
<script setup>
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { RouterLink } from 'vue-router'
const props = defineProps({
open: { type: Boolean, default: false },
node: { type: Object, default: null },
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
})
const emit = defineEmits(['close', 'info'])
const nodeLabel = computed(() => props.node?.label || props.node?.name || '')
function close() {
emit('close')
}
function onInfo() {
emit('info', props.node)
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;
text-decoration: none;
}
.graph-node-menu__item:hover {
background: var(--accent-dim);
}
.graph-node-menu__link {
color: var(--text);
}
</style>