Improve graph/map UX and move contact editing to dedicated pages.
Add viewport controls, collapsible panels with persisted state, filters modal, and refined context menus; preserve map layout on navigation and let contacts be edited with relations on /contacts/:id instead of list modals. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -43,6 +43,18 @@
|
||||
<p v-if="selectedContact" class="selected-summary">
|
||||
Выбран: <strong>{{ selectedContact.name }}</strong>
|
||||
</p>
|
||||
|
||||
<RelationLinkFields
|
||||
v-if="availableLinkTargets.length"
|
||||
ref="relationLinkRef"
|
||||
:options="availableLinkTargets"
|
||||
:initial-target-id="initialLinkToId"
|
||||
:conflict-mode="conflictMode"
|
||||
label="Связать с участником карты"
|
||||
placeholder="Выберите участника..."
|
||||
hint="Необязательно — при добавлении на карту будет создана связь."
|
||||
/>
|
||||
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
|
||||
<div class="modal-footer">
|
||||
@@ -65,16 +77,21 @@
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { listContacts } from '../application/usecases/contacts'
|
||||
import { normalizeApiError } from '../lib/api/errors'
|
||||
import RelationLinkFields from './RelationLinkFields.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
memberContactIds: { type: Array, default: () => [] },
|
||||
linkTargets: { type: Array, default: () => [] },
|
||||
initialLinkToId: { type: [String, Number], default: '' },
|
||||
conflictMode: { type: Boolean, default: false },
|
||||
onAdd: { type: Function, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const searchInputRef = ref(null)
|
||||
const relationLinkRef = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const selectedContactId = ref('')
|
||||
@@ -91,6 +108,12 @@ const selectedContact = computed(() =>
|
||||
searchResults.value.find((c) => String(c.id) === String(selectedContactId.value)) || null
|
||||
)
|
||||
|
||||
const availableLinkTargets = computed(() => {
|
||||
const selectedId = String(selectedContactId.value)
|
||||
if (!selectedId) return props.linkTargets
|
||||
return props.linkTargets.filter((t) => String(t.value) !== selectedId)
|
||||
})
|
||||
|
||||
function resetState() {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
@@ -98,6 +121,7 @@ function resetState() {
|
||||
searching.value = false
|
||||
saving.value = false
|
||||
error.value = ''
|
||||
relationLinkRef.value?.reset?.()
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
@@ -174,7 +198,8 @@ async function submit() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await props.onAdd(selectedContactId.value)
|
||||
const relationLink = relationLinkRef.value?.getRelationLink?.() ?? null
|
||||
await props.onAdd(selectedContactId.value, relationLink)
|
||||
} catch (e) {
|
||||
error.value = normalizeApiError(e).message
|
||||
} finally {
|
||||
|
||||
@@ -45,6 +45,13 @@
|
||||
<label>Заметки</label>
|
||||
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
||||
</div>
|
||||
<RelationLinkFields
|
||||
v-if="showRelationLink"
|
||||
ref="relationLinkRef"
|
||||
:options="linkToOptions"
|
||||
:initial-target-id="initialLinkToId"
|
||||
:conflict-mode="conflictMode"
|
||||
/>
|
||||
<component
|
||||
:is="Ext"
|
||||
v-for="(Ext, index) in contactFormExtensions"
|
||||
@@ -74,17 +81,23 @@ import { reactive, ref, watch, computed, onMounted } from 'vue'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { listMembershipsByContact } from '../application/usecases/networkMaps'
|
||||
import { getContactFormExtensions } from '../core/pluginRegistry'
|
||||
import RelationLinkFields from './RelationLinkFields.vue'
|
||||
|
||||
const props = defineProps({
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
initialMapIds: { type: Array, default: null },
|
||||
deletable: { type: Boolean, default: false },
|
||||
showRelationLink: { type: Boolean, default: false },
|
||||
linkToOptions: { type: Array, default: () => [] },
|
||||
initialLinkToId: { type: [String, Number], default: '' },
|
||||
conflictMode: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['submit', 'cancel', 'delete'])
|
||||
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const contactFormExtensions = getContactFormExtensions()
|
||||
const pluginTags = ref([])
|
||||
const relationLinkRef = ref(null)
|
||||
|
||||
const showDelete = computed(() => {
|
||||
if (props.deletable) return true
|
||||
@@ -150,7 +163,10 @@ onMounted(async () => {
|
||||
|
||||
function onSubmit() {
|
||||
const { mapIds, ...contactData } = form
|
||||
emit('submit', contactData, mapIds, { tags: [...pluginTags.value] })
|
||||
const relationLink = props.showRelationLink
|
||||
? relationLinkRef.value?.getRelationLink?.() ?? null
|
||||
: null
|
||||
emit('submit', contactData, mapIds, { tags: [...pluginTags.value] }, relationLink)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="contact-relations-section" :class="{ 'contact-relations-section--standalone': standalone }">
|
||||
<div class="contact-relations-section__header">
|
||||
<h4>Связи ({{ contactRelations.length }})</h4>
|
||||
<button class="btn btn-primary btn-sm" type="button" @click="showAddRelation = true">
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="contactRelations.length === 0" class="contact-relations-empty text-muted">
|
||||
Нет связей с другими контактами.
|
||||
</div>
|
||||
<div v-else class="relations-list">
|
||||
<div
|
||||
v-for="rel in contactRelations"
|
||||
:key="rel.id"
|
||||
class="relation-row"
|
||||
@click="openEditRelation(rel)"
|
||||
>
|
||||
<div class="relation-row__body">
|
||||
<div class="relation-row__main">
|
||||
<span class="relation-row__name">{{ otherContactName(rel) }}</span>
|
||||
<span :class="`badge badge-${rel.relation_type}`">{{ relLabel(rel.relation_type) }}</span>
|
||||
<span class="relation-row__intensity">{{ intensityLabel(rel.interaction_intensity) }}</span>
|
||||
</div>
|
||||
<div v-if="rel.description" class="relation-row__desc">{{ 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
@close="closeEditRelation"
|
||||
@updated="closeEditRelation"
|
||||
@deleted="closeEditRelation"
|
||||
/>
|
||||
|
||||
<div v-if="showAddRelation" class="modal-overlay nested-overlay" @click.self="showAddRelation = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Добавить связь</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="showAddRelation = false">✕</button>
|
||||
</div>
|
||||
<div v-if="relError" class="alert alert-error">{{ relError }}</div>
|
||||
<div class="form-group">
|
||||
<label>С кем связать</label>
|
||||
<SearchableSelect
|
||||
v-model="newRel.targetId"
|
||||
:options="contactSelectOptions"
|
||||
placeholder="Введите имя контакта..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Тип связи</label>
|
||||
<RelationTypeSelect v-model="newRel.type" :options="relationTypes" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<InteractionIntensitySelect v-model="newRel.interaction_intensity" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание (необязательно)</label>
|
||||
<input v-model="newRel.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" type="button" @click="showAddRelation = false">Отмена</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="!newRel.targetId" @click="addRelation">
|
||||
Создать связь
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import EditRelationModal from './EditRelationModal.vue'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
|
||||
import RelationTypeSelect from './RelationTypeSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
contactId: { type: [String, Number], required: true },
|
||||
standalone: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const store = useContactsStore()
|
||||
const showAddRelation = ref(false)
|
||||
const editRelationOpen = ref(false)
|
||||
const editRelationTarget = ref(null)
|
||||
const relationTypes = ref([])
|
||||
const interactionIntensities = ref([])
|
||||
const relError = ref('')
|
||||
const newRel = ref({
|
||||
targetId: '',
|
||||
type: 'acquaintance',
|
||||
description: '',
|
||||
interaction_intensity: 'intense',
|
||||
})
|
||||
|
||||
const contactRelations = computed(() =>
|
||||
store.relations.filter(
|
||||
(r) => String(r.source) === String(props.contactId) || String(r.target) === String(props.contactId)
|
||||
)
|
||||
)
|
||||
|
||||
const otherContacts = computed(() =>
|
||||
store.contacts
|
||||
.filter((c) => String(c.id) !== String(props.contactId))
|
||||
.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 intensityLabel(v) {
|
||||
return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
|
||||
}
|
||||
|
||||
function otherContactName(rel) {
|
||||
const cid = String(props.contactId)
|
||||
return String(rel.source) === cid ? rel.target_name : rel.source_name
|
||||
}
|
||||
|
||||
function openEditRelation(rel) {
|
||||
editRelationTarget.value = rel
|
||||
editRelationOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditRelation() {
|
||||
editRelationOpen.value = false
|
||||
editRelationTarget.value = null
|
||||
}
|
||||
|
||||
async function addRelation() {
|
||||
relError.value = ''
|
||||
try {
|
||||
await store.createRelation({
|
||||
source: props.contactId,
|
||||
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: '',
|
||||
interaction_intensity: 'intense',
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e.response?.data
|
||||
relError.value = typeof msg === 'object' ? JSON.stringify(msg) : String(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRelation(id) {
|
||||
if (!window.confirm('Удалить связь?')) return
|
||||
await store.deleteRelation(id)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.relations.length) {
|
||||
await store.fetchRelations()
|
||||
}
|
||||
if (!store.contacts.length) {
|
||||
await store.fetchContacts()
|
||||
}
|
||||
const [rt, intensities] = await Promise.all([
|
||||
store.fetchRelationTypes(),
|
||||
store.fetchNetworkMapChoices(),
|
||||
])
|
||||
relationTypes.value = rt
|
||||
interactionIntensities.value = intensities?.interaction_intensities || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contact-relations-section:not(.contact-relations-section--standalone) {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.contact-relations-section--standalone .contact-relations-section__header h4 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.contact-relations-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.contact-relations-section__header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.contact-relations-empty {
|
||||
font-size: 13px;
|
||||
}
|
||||
.relations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.relation-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.relation-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.relation-row:hover {
|
||||
background: var(--surface-alt);
|
||||
margin: 0 -8px;
|
||||
padding: 10px 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.relation-row__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.relation-row__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.relation-row__name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.relation-row__intensity {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.relation-row__desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.relation-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nested-overlay {
|
||||
z-index: 950;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,10 @@
|
||||
<ContactForm
|
||||
:initial="{}"
|
||||
:initial-map-ids="initialMapIds"
|
||||
:show-relation-link="showRelationLink"
|
||||
:link-to-options="linkToOptions"
|
||||
:initial-link-to-id="initialLinkToId"
|
||||
:conflict-mode="conflictMode"
|
||||
@submit="onSubmit"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
@@ -21,6 +25,10 @@ import ContactForm from './ContactForm.vue'
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
initialMapIds: { type: Array, default: () => [] },
|
||||
showRelationLink: { type: Boolean, default: false },
|
||||
linkToOptions: { type: Array, default: () => [] },
|
||||
initialLinkToId: { type: [String, Number], default: '' },
|
||||
conflictMode: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
@@ -29,7 +37,7 @@ function onCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onSubmit(contactData, mapIds, pluginPayload) {
|
||||
emit('created', contactData, mapIds, pluginPayload)
|
||||
function onSubmit(contactData, mapIds, pluginPayload, relationLink) {
|
||||
emit('created', contactData, mapIds, pluginPayload, relationLink)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,8 +14,15 @@
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onCreateContact">
|
||||
Добавить контакт
|
||||
<button
|
||||
v-for="item in actions"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="graph-node-menu__item"
|
||||
role="menuitem"
|
||||
@click="onSelect(item.id)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -28,16 +35,21 @@ const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => [{ id: 'create-contact', label: 'Добавить контакт' }],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'create-contact'])
|
||||
const emit = defineEmits(['close', 'create-contact', 'select'])
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onCreateContact() {
|
||||
emit('create-contact')
|
||||
function onSelect(id) {
|
||||
emit('select', id)
|
||||
if (id === 'create-contact') emit('create-contact')
|
||||
close()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,18 +14,15 @@
|
||||
@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'
|
||||
import { CONFLICT_RELATION_TYPES } from '../domain/conflictology'
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
@@ -36,17 +33,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'edit'])
|
||||
|
||||
const typeLabels = Object.fromEntries([
|
||||
...RELATION_TYPES,
|
||||
...CONFLICT_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')
|
||||
}
|
||||
@@ -90,18 +76,6 @@ onUnmounted(() => {
|
||||
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%;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="close">
|
||||
<div class="modal graph-filters-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Фильтры</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="close">✕</button>
|
||||
</div>
|
||||
|
||||
<p class="graph-filters-hint text-muted">
|
||||
Ctrl+клик (⌘+клик) по двум узлам — создать связь.
|
||||
<span v-if="linkSelectionCount === 1">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Типы связей</label>
|
||||
<RelationTypeFilters
|
||||
:relation-types="relationTypes"
|
||||
:active-values="activeFilters"
|
||||
@toggle="$emit('toggle', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="toolbarActions.length" class="form-group">
|
||||
<label>Дополнительно</label>
|
||||
<div class="graph-filters-actions">
|
||||
<button
|
||||
v-for="action in toolbarActions"
|
||||
:key="action.id"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="onToolbarAction(action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import RelationTypeFilters from './RelationTypeFilters.vue'
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
relationTypes: { type: Array, default: () => [] },
|
||||
activeFilters: { type: Array, default: () => [] },
|
||||
linkSelectionCount: { type: Number, default: 0 },
|
||||
linkSelection: { type: Array, default: () => [] },
|
||||
toolbarActions: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'toggle', 'toolbar-action'])
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onToolbarAction(action) {
|
||||
emit('toolbar-action', action)
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-filters-modal {
|
||||
max-width: 520px;
|
||||
}
|
||||
.graph-filters-hint {
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.graph-filters-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,8 @@
|
||||
<div class="graph-view-header">
|
||||
<h2>{{ title }}</h2>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" @click="$emit('reset')">
|
||||
<slot name="actions" />
|
||||
<button v-if="showReset" class="btn btn-secondary btn-sm" @click="$emit('reset')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
@@ -19,6 +20,7 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: 'Граф связей' },
|
||||
showReset: { type: Boolean, default: true },
|
||||
resetLabel: { type: String, default: 'Сбросить вид' },
|
||||
showPhysicsToggle: { type: Boolean, default: false },
|
||||
physicsEnabled: { type: Boolean, default: true },
|
||||
|
||||
@@ -14,24 +14,20 @@
|
||||
@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}`"
|
||||
:to="{ path: `/contacts/${node.id}`, query: { edit: '1' } }"
|
||||
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 { onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -41,19 +37,12 @@ const props = defineProps({
|
||||
y: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'info'])
|
||||
|
||||
const nodeLabel = computed(() => props.node?.label || props.node?.name || '')
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onInfo() {
|
||||
emit('info', props.node)
|
||||
close()
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.key === 'Escape' && props.open) close()
|
||||
}
|
||||
@@ -88,18 +77,6 @@ onUnmounted(() => {
|
||||
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%;
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
<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 class="map-switcher-controls">
|
||||
<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 map-switcher-settings"
|
||||
title="Настройки карты"
|
||||
@click="$emit('manage', modelValue)"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,15 +44,28 @@ function onSelect(event) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.map-switcher-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.map-switcher-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.map-switcher-select {
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
width: auto;
|
||||
}
|
||||
.map-switcher-settings {
|
||||
min-width: 34px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,16 +18,10 @@
|
||||
</div>
|
||||
<div class="network-map-actions">
|
||||
<slot name="toolbar" />
|
||||
<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">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||
</svg>
|
||||
По центру
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="network-map-legend">
|
||||
<div v-show="!collapsed && showLegend" class="network-map-legend">
|
||||
<slot name="legend" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,25 +35,28 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showLegend: { type: Boolean, default: true },
|
||||
})
|
||||
defineEmits(['toggle-collapse', 'fit'])
|
||||
defineEmits(['toggle-collapse'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-top-panel {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.network-map-top-panel.collapsed {
|
||||
min-height: 0;
|
||||
border-bottom: none;
|
||||
min-height: 28px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.panel-toggle-btn {
|
||||
position: absolute;
|
||||
bottom: -11px;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
z-index: 4;
|
||||
z-index: 6;
|
||||
width: 28px;
|
||||
height: 20px;
|
||||
margin-left: -14px;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="relation-link-fields">
|
||||
<div class="form-group">
|
||||
<label>{{ label }}</label>
|
||||
<SearchableSelect
|
||||
v-model="targetId"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
<p v-if="hint" class="field-hint text-muted">{{ hint }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="targetId">
|
||||
<div class="form-group">
|
||||
<label>{{ conflictMode ? 'Тип связи в конфликте' : 'Тип связи' }}</label>
|
||||
<RelationTypeSelect
|
||||
v-model="relationType"
|
||||
:options="conflictMode ? conflictTypes : undefined"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!conflictMode" class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<InteractionIntensitySelect v-model="intensity" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
import RelationTypeSelect from './RelationTypeSelect.vue'
|
||||
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
|
||||
import { CONFLICT_RELATION_TYPES } from '../domain/conflictology'
|
||||
|
||||
const conflictTypes = CONFLICT_RELATION_TYPES
|
||||
|
||||
const props = defineProps({
|
||||
options: { type: Array, default: () => [] },
|
||||
initialTargetId: { type: [String, Number], default: '' },
|
||||
conflictMode: { type: Boolean, default: false },
|
||||
label: { type: String, default: 'Связать с контактом' },
|
||||
placeholder: { type: String, default: 'Выберите контакт...' },
|
||||
hint: {
|
||||
type: String,
|
||||
default: 'Необязательно — контакт появится на графе и будет связан с выбранным.',
|
||||
},
|
||||
})
|
||||
|
||||
const targetId = ref('')
|
||||
const relationType = ref('acquaintance')
|
||||
const intensity = ref('intense')
|
||||
|
||||
const relationLink = computed(() => {
|
||||
if (!targetId.value) return null
|
||||
return {
|
||||
targetId: targetId.value,
|
||||
type: relationType.value,
|
||||
intensity: intensity.value,
|
||||
description: '',
|
||||
}
|
||||
})
|
||||
|
||||
function reset() {
|
||||
targetId.value = props.initialTargetId ? String(props.initialTargetId) : ''
|
||||
relationType.value = props.conflictMode ? 'conflict_neutral' : 'acquaintance'
|
||||
intensity.value = 'intense'
|
||||
}
|
||||
|
||||
function getRelationLink() {
|
||||
return relationLink.value
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.initialTargetId, props.conflictMode],
|
||||
() => reset(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ getRelationLink, reset })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.relation-link-fields + .relation-link-fields {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user