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:
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>Важность (1–5)</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>
|
||||
Reference in New Issue
Block a user