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:
@@ -92,13 +92,13 @@ social-graph/
|
||||
| GET | `/api/network-map-graph/` | Граф карты сети |
|
||||
| GET | `/api/relation-types/` | Типы связей |
|
||||
| GET | `/api/network-map-choices/` | Справочники карты |
|
||||
| POST | `/api/import/` | Импорт CSV/JSON (legacy) |
|
||||
| POST | `/api/import/` | Импорт CSV/JSON/vCard (legacy) |
|
||||
|
||||
В режиме `local` импорт и бэкап выполняются в браузере (экран **Импорт**).
|
||||
|
||||
## Импорт и бэкап (local-first)
|
||||
|
||||
- **CSV / JSON** — экран «Импорт», парсинг на клиенте.
|
||||
- **CSV / JSON / vCard (.vcf)** — экран «Импорт», парсинг на клиенте.
|
||||
- **Экспорт / импорт бэкапа** — JSON или зашифрованный `.sgpkg` (WebCrypto, пароль опционален).
|
||||
|
||||
## Production
|
||||
@@ -153,5 +153,4 @@ docker compose exec frontend npm test -- --run
|
||||
- [ ] Синхронизация и shared-workspace
|
||||
- [ ] Поиск по организации и должности
|
||||
- [ ] История изменений контакта
|
||||
- [ ] Импорт из vCard (.vcf)
|
||||
- [ ] Уведомления / дни рождения
|
||||
|
||||
@@ -237,14 +237,88 @@ def _normalize_rows(data):
|
||||
return []
|
||||
|
||||
|
||||
def _unfold_vcard_lines(text):
|
||||
lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
||||
unfolded = []
|
||||
for line in lines:
|
||||
if line.startswith((' ', '\t')) and unfolded:
|
||||
unfolded[-1] += line[1:]
|
||||
else:
|
||||
unfolded.append(line)
|
||||
return unfolded
|
||||
|
||||
|
||||
def _unescape_vcard_value(value):
|
||||
return (
|
||||
str(value or '')
|
||||
.replace('\\n', '\n')
|
||||
.replace('\\N', '\n')
|
||||
.replace('\\,', ',')
|
||||
.replace('\\;', ';')
|
||||
.replace('\\\\', '\\')
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def _name_from_vcard_n(value):
|
||||
parts = _unescape_vcard_value(value).split(';')
|
||||
family = (parts[0] if len(parts) > 0 else '').strip()
|
||||
given = (parts[1] if len(parts) > 1 else '').strip()
|
||||
additional = (parts[2] if len(parts) > 2 else '').strip()
|
||||
return ' '.join(filter(None, [given, additional, family])).strip()
|
||||
|
||||
|
||||
def _parse_vcf_rows(text):
|
||||
props = None
|
||||
rows = []
|
||||
for line in _unfold_vcard_lines(text):
|
||||
trimmed = line.strip()
|
||||
if not trimmed:
|
||||
continue
|
||||
upper = trimmed.upper()
|
||||
if upper == 'BEGIN:VCARD':
|
||||
props = {}
|
||||
continue
|
||||
if upper == 'END:VCARD':
|
||||
if props:
|
||||
name = (
|
||||
(props.get('FN') or [''])[0]
|
||||
or _name_from_vcard_n((props.get('N') or [''])[0])
|
||||
).strip()
|
||||
org_raw = (props.get('ORG') or [''])[0]
|
||||
org = org_raw.split(';')[0].strip() if org_raw else ''
|
||||
email = (props.get('EMAIL') or [''])[0].replace('mailto:', '').strip()
|
||||
phone = (props.get('TEL') or [''])[0].replace('tel:', '').strip()
|
||||
rows.append({
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'organization': org,
|
||||
'position': (props.get('TITLE') or [''])[0].strip(),
|
||||
'notes': '\n'.join(props.get('NOTE') or []).strip(),
|
||||
})
|
||||
props = None
|
||||
continue
|
||||
if props is None:
|
||||
continue
|
||||
if ':' not in trimmed:
|
||||
continue
|
||||
raw_key, value = trimmed.split(':', 1)
|
||||
key = raw_key.split(';')[0].upper()
|
||||
decoded = _unescape_vcard_value(value.replace('mailto:', '').replace('tel:', ''))
|
||||
props.setdefault(key, []).append(decoded)
|
||||
return rows
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def import_contacts(request):
|
||||
"""
|
||||
Импорт контактов из CSV или JSON.
|
||||
Импорт контактов из CSV, JSON или vCard (.vcf).
|
||||
|
||||
CSV: name,email,phone,organization,position,notes
|
||||
JSON (плоский): [{"name": "...", ...}, ...]
|
||||
JSON (Monica CRM): {"contacts": [{"first_name": ..., "last_name": ...}, ...]}
|
||||
vCard: экспорт из телефона / Google Contacts (.vcf)
|
||||
"""
|
||||
file = request.FILES.get('file')
|
||||
if not file:
|
||||
@@ -268,9 +342,17 @@ def import_contacts(request):
|
||||
{'error': 'Не удалось распознать формат JSON. Ожидается массив контактов или экспорт Monica CRM.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
elif filename.endswith('.vcf') or filename.endswith('.vcard'):
|
||||
text = file.read().decode('utf-8-sig')
|
||||
rows = _parse_vcf_rows(text)
|
||||
if not rows:
|
||||
return Response(
|
||||
{'error': 'В файле vCard не найдено контактов.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Поддерживаются только CSV и JSON файлы.'},
|
||||
{'error': 'Поддерживаются только CSV, JSON и vCard (.vcf) файлы.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ sudo systemctl reload apache2 # или: sudo nginx -s reload
|
||||
|
||||
| Источник | Действие |
|
||||
|----------|----------|
|
||||
| CSV/JSON | UI → Импорт |
|
||||
| CSV / JSON / **vCard (.vcf)** | UI → Импорт |
|
||||
| Старый Django SQLite | экспорт JSON → Импорт |
|
||||
| Локальный бэкап `.json` / `.sgpkg` | UI → «Импорт бэкапа» |
|
||||
|
||||
|
||||
@@ -42,3 +42,9 @@ export async function deleteContact(id) {
|
||||
payloadPatch: {},
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteContacts(ids = []) {
|
||||
for (const id of ids) {
|
||||
await deleteContact(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ function nodeFromContact(c) {
|
||||
}
|
||||
}
|
||||
|
||||
function edgeFromRelation(r) {
|
||||
export function edgeFromRelation(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
from: r.source,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { listContacts, createContact } from './contacts'
|
||||
import { listRelations, createRelation } from './relations'
|
||||
import { listRelations } from './relations'
|
||||
import { localDb } from '../../infrastructure/db/localDb'
|
||||
import { parseVcf } from '../../lib/import/vcard'
|
||||
import { serializeContactsExport } from '../../lib/export/contacts'
|
||||
|
||||
function isLikelyEmail(value) {
|
||||
return value.includes('@') && value.includes('.')
|
||||
@@ -58,8 +60,13 @@ export async function importContactsFromFile(file) {
|
||||
rows = parseCsv(rawText)
|
||||
} else if (name.endsWith('.json')) {
|
||||
rows = normalizeRows(JSON.parse(rawText))
|
||||
} else if (name.endsWith('.vcf') || name.endsWith('.vcard')) {
|
||||
rows = parseVcf(rawText)
|
||||
if (!rows.length) {
|
||||
throw new Error('В файле vCard не найдено контактов.')
|
||||
}
|
||||
} else {
|
||||
throw new Error('Поддерживаются только CSV и JSON файлы.')
|
||||
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf) файлы.')
|
||||
}
|
||||
|
||||
let created = 0
|
||||
@@ -69,7 +76,7 @@ export async function importContactsFromFile(file) {
|
||||
const payload = toContactPayload(rows[i])
|
||||
if (!payload.name) {
|
||||
skipped += 1
|
||||
errors.push(`Строка ${i + 1}: отсутствует поле "name"`)
|
||||
errors.push(`Запись ${i + 1}: отсутствует имя контакта`)
|
||||
continue
|
||||
}
|
||||
await createContact(payload)
|
||||
@@ -79,6 +86,28 @@ export async function importContactsFromFile(file) {
|
||||
return { total: rows.length, created, skipped, errors }
|
||||
}
|
||||
|
||||
const ALLOWED_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf'])
|
||||
|
||||
export async function exportContacts({ format = 'csv' } = {}) {
|
||||
const normalized = String(format || 'csv').toLowerCase()
|
||||
if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
|
||||
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).')
|
||||
}
|
||||
|
||||
const contacts = await listContacts()
|
||||
if (!contacts.length) {
|
||||
throw new Error('Нет контактов для экспорта.')
|
||||
}
|
||||
|
||||
const { filename, mime, content } = serializeContactsExport(contacts, normalized)
|
||||
return {
|
||||
filename,
|
||||
blob: new Blob([content], { type: mime }),
|
||||
count: contacts.length,
|
||||
format: normalized,
|
||||
}
|
||||
}
|
||||
|
||||
function uint8ToBase64(bytes) {
|
||||
let binary = ''
|
||||
bytes.forEach((b) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export function useCtrlLinkSelection({ onPairSelected, maxSelection = 2 } = {}) {
|
||||
const linkSelection = ref([])
|
||||
|
||||
const linkSelectionCount = computed(() => linkSelection.value.length)
|
||||
|
||||
function isLinkSelected(id) {
|
||||
const sid = String(id)
|
||||
return linkSelection.value.some((item) => String(item.id) === sid)
|
||||
}
|
||||
|
||||
function clearLinkSelection() {
|
||||
linkSelection.value = []
|
||||
}
|
||||
|
||||
function pickContact(contact) {
|
||||
if (!contact?.id) return
|
||||
const sid = String(contact.id)
|
||||
|
||||
if (isLinkSelected(sid)) {
|
||||
linkSelection.value = linkSelection.value.filter((item) => String(item.id) !== sid)
|
||||
return
|
||||
}
|
||||
|
||||
if (linkSelection.value.length >= maxSelection) {
|
||||
linkSelection.value = [linkSelection.value[0], contact]
|
||||
} else {
|
||||
linkSelection.value = [...linkSelection.value, contact]
|
||||
}
|
||||
|
||||
if (linkSelection.value.length >= maxSelection) {
|
||||
onPairSelected?.([...linkSelection.value])
|
||||
}
|
||||
}
|
||||
|
||||
function handleCtrlPick(contact, event) {
|
||||
if (!event?.ctrlKey && !event?.metaKey) return false
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
pickContact(contact)
|
||||
return true
|
||||
}
|
||||
|
||||
function toContact(node) {
|
||||
if (!node?.id) return null
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name || node.label || String(node.id),
|
||||
}
|
||||
}
|
||||
|
||||
function handleCtrlPickNode(node, event) {
|
||||
const contact = toContact(node)
|
||||
if (!contact) return false
|
||||
return handleCtrlPick(contact, event)
|
||||
}
|
||||
|
||||
return {
|
||||
linkSelection,
|
||||
linkSelectionCount,
|
||||
isLinkSelected,
|
||||
clearLinkSelection,
|
||||
pickContact,
|
||||
handleCtrlPick,
|
||||
handleCtrlPickNode,
|
||||
toContact,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useGraphNodeContextMenu() {
|
||||
const contextMenuOpen = ref(false)
|
||||
const contextMenuNode = ref(null)
|
||||
const contextMenuX = ref(0)
|
||||
const contextMenuY = ref(0)
|
||||
|
||||
function openContextMenu(node, event) {
|
||||
if (!node || !event) return
|
||||
contextMenuNode.value = node
|
||||
contextMenuX.value = event.clientX
|
||||
contextMenuY.value = event.clientY
|
||||
contextMenuOpen.value = true
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenuOpen.value = false
|
||||
contextMenuNode.value = null
|
||||
}
|
||||
|
||||
function attachNodeContextHandlers(network, getNodes) {
|
||||
const onContext = (params) => {
|
||||
const domEvent = params.event?.srcEvent || params.event
|
||||
domEvent?.preventDefault?.()
|
||||
if (params.nodes?.length > 0) {
|
||||
const id = params.nodes[0]
|
||||
const node = getNodes().find((n) => String(n.id) === String(id))
|
||||
if (node) openContextMenu(node, domEvent)
|
||||
return
|
||||
}
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
network.on('oncontext', onContext)
|
||||
return () => network.off('oncontext', onContext)
|
||||
}
|
||||
|
||||
return {
|
||||
contextMenuOpen,
|
||||
contextMenuNode,
|
||||
contextMenuX,
|
||||
contextMenuY,
|
||||
openContextMenu,
|
||||
closeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { localDb } from '../db/localDb'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
@@ -24,12 +25,24 @@ function withDefaults(payload = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function sameId(a, b) {
|
||||
return String(a) === String(b)
|
||||
}
|
||||
|
||||
async function findActiveContact(id) {
|
||||
const direct = await localDb.contacts.get(id)
|
||||
if (direct && !direct.deletedAt) return direct
|
||||
const sid = String(id)
|
||||
const all = await localDb.contacts.toArray()
|
||||
return all.find((c) => !c.deletedAt && sameId(c.id, sid)) || null
|
||||
}
|
||||
|
||||
async function relationsCount(id) {
|
||||
const [sourceCount, targetCount] = await Promise.all([
|
||||
localDb.relations.where('source').equals(id).and((r) => !r.deletedAt).count(),
|
||||
localDb.relations.where('target').equals(id).and((r) => !r.deletedAt).count(),
|
||||
])
|
||||
return sourceCount + targetCount
|
||||
const sid = String(id)
|
||||
const all = await localDb.relations.toArray()
|
||||
return all.filter(
|
||||
(r) => !r.deletedAt && (sameId(r.source, sid) || sameId(r.target, sid))
|
||||
).length
|
||||
}
|
||||
|
||||
async function hydrate(contact) {
|
||||
@@ -47,18 +60,19 @@ export const localContactRepository = {
|
||||
.filter((c) => !c.deletedAt)
|
||||
.filter((c) => c.name.toLowerCase().includes(String(search || '').toLowerCase()))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||
return Promise.all(filtered.map(hydrate))
|
||||
const hydrated = await Promise.all(filtered.map(hydrate))
|
||||
return hydrated.filter(Boolean)
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const contact = await localDb.contacts.get(id)
|
||||
const contact = await findActiveContact(id)
|
||||
return hydrate(contact)
|
||||
},
|
||||
|
||||
async create(payload) {
|
||||
const ts = nowIso()
|
||||
const record = withDefaults(payload)
|
||||
const id = crypto.randomUUID()
|
||||
const id = generateId()
|
||||
await localDb.contacts.put({
|
||||
...record,
|
||||
id,
|
||||
@@ -71,24 +85,42 @@ export const localContactRepository = {
|
||||
},
|
||||
|
||||
async update(id, payload) {
|
||||
const existing = await localDb.contacts.get(id)
|
||||
if (!existing || existing.deletedAt) {
|
||||
const existing = await findActiveContact(id)
|
||||
if (!existing) {
|
||||
throw new Error('Контакт не найден')
|
||||
}
|
||||
await localDb.contacts.update(id, {
|
||||
await localDb.contacts.update(existing.id, {
|
||||
...payload,
|
||||
updatedAt: nowIso(),
|
||||
version: Number(existing.version || 1) + 1,
|
||||
})
|
||||
return this.getById(id)
|
||||
return this.getById(existing.id)
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
const contact = await findActiveContact(id)
|
||||
if (!contact) {
|
||||
throw new Error('Контакт не найден')
|
||||
}
|
||||
const key = contact.id
|
||||
const sid = String(key)
|
||||
const ts = nowIso()
|
||||
await localDb.contacts.update(id, { deletedAt: ts, updatedAt: ts })
|
||||
await localDb.contacts.update(key, {
|
||||
deletedAt: ts,
|
||||
updatedAt: ts,
|
||||
version: Number(contact.version || 1) + 1,
|
||||
})
|
||||
const relations = await localDb.relations
|
||||
.filter((r) => !r.deletedAt && (r.source === id || r.target === id))
|
||||
.filter((r) => !r.deletedAt && (sameId(r.source, sid) || sameId(r.target, sid)))
|
||||
.toArray()
|
||||
await Promise.all(relations.map((r) => localDb.relations.update(r.id, { deletedAt: ts, updatedAt: ts })))
|
||||
await Promise.all(
|
||||
relations.map((r) =>
|
||||
localDb.relations.update(r.id, {
|
||||
deletedAt: ts,
|
||||
updatedAt: ts,
|
||||
version: Number(r.version || 1) + 1,
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { localDb } from '../db/localDb'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
@@ -30,7 +31,7 @@ export const localRelationRepository = {
|
||||
throw new Error('Нельзя создать связь контакта с самим собой.')
|
||||
}
|
||||
const ts = nowIso()
|
||||
const id = crypto.randomUUID()
|
||||
const id = generateId()
|
||||
await localDb.relations.put({
|
||||
id,
|
||||
source: payload.source,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { localDb } from '../db/localDb'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
export async function appendChange({ entityType, entityId, op, payloadPatch, workspaceId = 'personal' }) {
|
||||
const id = crypto.randomUUID()
|
||||
const id = generateId()
|
||||
await localDb.changelog.put({
|
||||
id,
|
||||
entityType,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
const CSV_HEADERS = ['name', 'email', 'phone', 'organization', 'position', 'notes']
|
||||
|
||||
export function contactToExportRow(contact = {}) {
|
||||
return {
|
||||
name: String(contact.name || '').trim(),
|
||||
email: String(contact.email || '').trim(),
|
||||
phone: String(contact.phone || '').trim(),
|
||||
organization: String(contact.organization || '').trim(),
|
||||
position: String(contact.position || '').trim(),
|
||||
notes: String(contact.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function escapeCsvField(value) {
|
||||
const text = String(value ?? '')
|
||||
if (/[",\n\r]/.test(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export function contactsToCsv(contacts = []) {
|
||||
const rows = contacts.map(contactToExportRow)
|
||||
const lines = [CSV_HEADERS.join(',')]
|
||||
for (const row of rows) {
|
||||
lines.push(CSV_HEADERS.map((key) => escapeCsvField(row[key])).join(','))
|
||||
}
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
export function contactsToJson(contacts = []) {
|
||||
return JSON.stringify(contacts.map(contactToExportRow), null, 2)
|
||||
}
|
||||
|
||||
function escapeVcardValue(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
}
|
||||
|
||||
function vcardLine(key, value) {
|
||||
if (!value) return null
|
||||
return `${key}:${escapeVcardValue(value)}`
|
||||
}
|
||||
|
||||
function contactToVcard(contact) {
|
||||
const row = contactToExportRow(contact)
|
||||
if (!row.name) return null
|
||||
|
||||
const lines = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
vcardLine('FN', row.name),
|
||||
vcardLine('EMAIL', row.email),
|
||||
vcardLine('TEL', row.phone),
|
||||
vcardLine('ORG', row.organization),
|
||||
vcardLine('TITLE', row.position),
|
||||
vcardLine('NOTE', row.notes),
|
||||
'END:VCARD',
|
||||
].filter(Boolean)
|
||||
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
export function contactsToVcf(contacts = []) {
|
||||
const cards = contacts.map(contactToVcard).filter(Boolean)
|
||||
return cards.length ? `${cards.join('\r\n')}\r\n` : ''
|
||||
}
|
||||
|
||||
const EXPORT_FORMATS = {
|
||||
csv: {
|
||||
ext: 'csv',
|
||||
mime: 'text/csv;charset=utf-8',
|
||||
serialize: contactsToCsv,
|
||||
},
|
||||
json: {
|
||||
ext: 'json',
|
||||
mime: 'application/json;charset=utf-8',
|
||||
serialize: contactsToJson,
|
||||
},
|
||||
vcf: {
|
||||
ext: 'vcf',
|
||||
mime: 'text/vcard;charset=utf-8',
|
||||
serialize: contactsToVcf,
|
||||
},
|
||||
}
|
||||
|
||||
export function serializeContactsExport(contacts = [], format = 'csv') {
|
||||
const config = EXPORT_FORMATS[format] || EXPORT_FORMATS.csv
|
||||
return {
|
||||
format,
|
||||
filename: `contacts-export-${Date.now()}.${config.ext}`,
|
||||
mime: config.mime,
|
||||
content: config.serialize(contacts),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
contactToExportRow,
|
||||
contactsToCsv,
|
||||
contactsToJson,
|
||||
contactsToVcf,
|
||||
serializeContactsExport,
|
||||
} from './contacts'
|
||||
|
||||
const sample = [
|
||||
{
|
||||
name: 'Иван Иванов',
|
||||
email: 'ivan@example.com',
|
||||
phone: '+79000000001',
|
||||
organization: 'ООО Ромашка',
|
||||
position: 'Директор',
|
||||
notes: 'Важный контакт',
|
||||
},
|
||||
{
|
||||
name: 'Петр, "Старший"',
|
||||
email: '',
|
||||
phone: '',
|
||||
organization: 'Компания, ООО',
|
||||
position: '',
|
||||
notes: 'Строка\nс переносом',
|
||||
},
|
||||
]
|
||||
|
||||
describe('contacts export', () => {
|
||||
it('normalizes contact fields', () => {
|
||||
expect(contactToExportRow({ name: ' А ', email: null })).toEqual({
|
||||
name: 'А',
|
||||
email: '',
|
||||
phone: '',
|
||||
organization: '',
|
||||
position: '',
|
||||
notes: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('exports CSV with escaped fields', () => {
|
||||
const csv = contactsToCsv(sample)
|
||||
expect(csv).toContain('name,email,phone,organization,position,notes')
|
||||
expect(csv).toContain('Иван Иванов,ivan@example.com,+79000000001,ООО Ромашка,Директор,Важный контакт')
|
||||
expect(csv).toContain('"Петр, ""Старший""",,"","Компания, ООО",,"Строка\nс переносом"')
|
||||
})
|
||||
|
||||
it('exports JSON array compatible with import', () => {
|
||||
const parsed = JSON.parse(contactsToJson(sample))
|
||||
expect(parsed).toHaveLength(2)
|
||||
expect(parsed[0].name).toBe('Иван Иванов')
|
||||
expect(parsed[1].organization).toBe('Компания, ООО')
|
||||
})
|
||||
|
||||
it('exports vCard 3.0 cards', () => {
|
||||
const vcf = contactsToVcf(sample)
|
||||
expect(vcf).toContain('BEGIN:VCARD')
|
||||
expect(vcf).toContain('VERSION:3.0')
|
||||
expect(vcf).toContain('FN:Иван Иванов')
|
||||
expect(vcf).toContain('EMAIL:ivan@example.com')
|
||||
expect(vcf).toContain('ORG:ООО Ромашка')
|
||||
expect(vcf).toContain('FN:Петр\\, "Старший"')
|
||||
expect(vcf).toContain('ORG:Компания\\, ООО')
|
||||
expect(vcf).toContain('NOTE:Строка\\nс переносом')
|
||||
})
|
||||
|
||||
it('serializes selected format metadata', () => {
|
||||
const result = serializeContactsExport(sample, 'vcf')
|
||||
expect(result.format).toBe('vcf')
|
||||
expect(result.filename).toMatch(/\.vcf$/)
|
||||
expect(result.mime).toContain('vcard')
|
||||
expect(result.content).toContain('END:VCARD')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// Палитра для связных компонент графа (читаема на светлой и тёмной теме).
|
||||
export const CLUSTER_PALETTE_LIGHT = [
|
||||
{ bg: '#dce8fc', border: '#4a7ad9', highlight: '#b8cff5' },
|
||||
{ bg: '#d4f5ea', border: '#3cb896', highlight: '#a8e8d4' },
|
||||
{ bg: '#fde8d4', border: '#e0944a', highlight: '#f5d4b0' },
|
||||
{ bg: '#f0d9f7', border: '#a86bc9', highlight: '#ddb8ef' },
|
||||
{ bg: '#fce4ec', border: '#d96b8a', highlight: '#f5b8c8' },
|
||||
{ bg: '#e0f2f4', border: '#4aabb8', highlight: '#b0dde4' },
|
||||
{ bg: '#fef9dc', border: '#c9a83a', highlight: '#f5e8a8' },
|
||||
{ bg: '#e8eaf6', border: '#6b74c9', highlight: '#c0c5ef' },
|
||||
{ bg: '#e8f5e9', border: '#5a9e5c', highlight: '#b8dfb9' },
|
||||
{ bg: '#fff3e0', border: '#c97f3a', highlight: '#f5d0a8' },
|
||||
]
|
||||
|
||||
export const CLUSTER_PALETTE_DARK = [
|
||||
{ bg: '#1e3354', border: '#5b8dee', highlight: '#2a4570' },
|
||||
{ bg: '#1a3d34', border: '#4ecca3', highlight: '#255548' },
|
||||
{ bg: '#3d2e1a', border: '#f4a261', highlight: '#524028' },
|
||||
{ bg: '#352440', border: '#c49ae0', highlight: '#453055' },
|
||||
{ bg: '#3d2430', border: '#e07a94', highlight: '#523040' },
|
||||
{ bg: '#1a3538', border: '#5ec4d0', highlight: '#254548' },
|
||||
{ bg: '#3d3818', border: '#d4b050', highlight: '#524a28' },
|
||||
{ bg: '#252840', border: '#8890e0', highlight: '#323550' },
|
||||
{ bg: '#1a3520', border: '#6ec072', highlight: '#254530' },
|
||||
{ bg: '#3d3018', border: '#e0a050', highlight: '#524028' },
|
||||
]
|
||||
|
||||
export const ISOLATE_CLUSTER_LIGHT = {
|
||||
bg: '#f0f2f7',
|
||||
border: '#a8afc4',
|
||||
highlight: '#d8dce8',
|
||||
}
|
||||
|
||||
export const ISOLATE_CLUSTER_DARK = {
|
||||
bg: '#2a2f42',
|
||||
border: '#5a6078',
|
||||
highlight: '#363c52',
|
||||
}
|
||||
|
||||
export function clusterColor(clusterIndex, isDark = false) {
|
||||
if (clusterIndex == null || clusterIndex < 0) {
|
||||
return isDark ? ISOLATE_CLUSTER_DARK : ISOLATE_CLUSTER_LIGHT
|
||||
}
|
||||
const palette = isDark ? CLUSTER_PALETTE_DARK : CLUSTER_PALETTE_LIGHT
|
||||
return palette[clusterIndex % palette.length]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
function find(parent, id) {
|
||||
const sid = String(id)
|
||||
if (!parent.has(sid)) parent.set(sid, sid)
|
||||
if (parent.get(sid) !== sid) parent.set(sid, find(parent, parent.get(sid)))
|
||||
return parent.get(sid)
|
||||
}
|
||||
|
||||
function union(parent, a, b) {
|
||||
const ra = find(parent, a)
|
||||
const rb = find(parent, b)
|
||||
if (ra !== rb) parent.set(ra, rb)
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает Map<nodeId, clusterIndex>.
|
||||
* Связные компоненты из 2+ узлов получают уникальный индекс цвета,
|
||||
* изолированные узлы — -1 (нейтральный цвет).
|
||||
*/
|
||||
export function computeClusterMap(nodes = [], edges = []) {
|
||||
const parent = new Map()
|
||||
for (const node of nodes) find(parent, node.id)
|
||||
for (const edge of edges) union(parent, edge.from, edge.to)
|
||||
|
||||
const componentSizes = new Map()
|
||||
for (const node of nodes) {
|
||||
const root = find(parent, node.id)
|
||||
componentSizes.set(root, (componentSizes.get(root) || 0) + 1)
|
||||
}
|
||||
|
||||
const rootToCluster = new Map()
|
||||
let nextCluster = 0
|
||||
for (const node of nodes) {
|
||||
const root = find(parent, node.id)
|
||||
if (rootToCluster.has(root)) continue
|
||||
const size = componentSizes.get(root) || 1
|
||||
rootToCluster.set(root, size >= 2 ? nextCluster++ : -1)
|
||||
}
|
||||
|
||||
const map = new Map()
|
||||
for (const node of nodes) {
|
||||
const root = find(parent, node.id)
|
||||
map.set(String(node.id), rootToCluster.get(root) ?? -1)
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeClusterMap } from './clusters'
|
||||
|
||||
describe('computeClusterMap', () => {
|
||||
const nodes = [
|
||||
{ id: 'a' },
|
||||
{ id: 'b' },
|
||||
{ id: 'c' },
|
||||
{ id: 'd' },
|
||||
]
|
||||
|
||||
it('assigns same cluster to connected nodes', () => {
|
||||
const map = computeClusterMap(nodes, [
|
||||
{ from: 'a', to: 'b' },
|
||||
{ from: 'b', to: 'c' },
|
||||
])
|
||||
expect(map.get('a')).toBe(map.get('b'))
|
||||
expect(map.get('b')).toBe(map.get('c'))
|
||||
expect(map.get('a')).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('marks isolated nodes with -1', () => {
|
||||
const map = computeClusterMap(nodes, [{ from: 'a', to: 'b' }])
|
||||
expect(map.get('d')).toBe(-1)
|
||||
})
|
||||
|
||||
it('assigns different clusters to disconnected groups', () => {
|
||||
const map = computeClusterMap(nodes, [
|
||||
{ from: 'a', to: 'b' },
|
||||
{ from: 'c', to: 'd' },
|
||||
])
|
||||
expect(map.get('a')).not.toBe(map.get('c'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
function joinContinuationLines(lines) {
|
||||
const joined = []
|
||||
for (const line of lines) {
|
||||
if (
|
||||
joined.length &&
|
||||
joined[joined.length - 1].endsWith('=') &&
|
||||
line &&
|
||||
!/^(BEGIN|END):/i.test(line.trim())
|
||||
) {
|
||||
joined[joined.length - 1] += line
|
||||
} else {
|
||||
joined.push(line)
|
||||
}
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
function normalizeVcardLines(text) {
|
||||
return joinContinuationLines(unfoldLines(text))
|
||||
}
|
||||
|
||||
function unfoldLines(text) {
|
||||
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
|
||||
const unfolded = []
|
||||
for (const line of lines) {
|
||||
if (/^[ \t]/.test(line) && unfolded.length) {
|
||||
unfolded[unfolded.length - 1] += line.slice(1)
|
||||
} else {
|
||||
unfolded.push(line)
|
||||
}
|
||||
}
|
||||
return unfolded
|
||||
}
|
||||
|
||||
function decodeQuotedPrintable(value) {
|
||||
const compact = String(value || '').replace(/=\r?\n/g, '').replace(/=$/, '')
|
||||
const bytes = []
|
||||
for (let i = 0; i < compact.length; ) {
|
||||
if (compact[i] === '=' && /^[0-9A-Fa-f]{2}/.test(compact.slice(i + 1, i + 3))) {
|
||||
bytes.push(parseInt(compact.slice(i + 1, i + 3), 16))
|
||||
i += 3
|
||||
} else if (compact[i] === '=') {
|
||||
i += 1
|
||||
} else {
|
||||
bytes.push(compact.charCodeAt(i))
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(new Uint8Array(bytes))
|
||||
}
|
||||
|
||||
function parsePropertyKey(rawKey) {
|
||||
const parts = rawKey.split(';')
|
||||
const name = (parts[0] || '').toUpperCase()
|
||||
const params = {}
|
||||
for (let i = 1; i < parts.length; i += 1) {
|
||||
const segment = parts[i]
|
||||
const eq = segment.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
const key = segment.slice(0, eq).toUpperCase()
|
||||
params[key] = segment.slice(eq + 1)
|
||||
}
|
||||
return { name, params }
|
||||
}
|
||||
|
||||
function unescapeValue(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\n/gi, '\n')
|
||||
.replace(/\\N/g, '\n')
|
||||
.replace(/\\,/g, ',')
|
||||
.replace(/\\;/g, ';')
|
||||
.replace(/\\\\/g, '\\')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function decodePropertyValue(value, params) {
|
||||
const encoding = String(params.ENCODING || '').toUpperCase()
|
||||
if (encoding === 'QUOTED-PRINTABLE') {
|
||||
return decodeQuotedPrintable(value).trim()
|
||||
}
|
||||
return unescapeValue(value)
|
||||
}
|
||||
|
||||
function stripMailto(value) {
|
||||
return String(value || '').replace(/^mailto:/i, '').replace(/^tel:/i, '').trim()
|
||||
}
|
||||
|
||||
function pushValue(map, key, value) {
|
||||
if (!value) return
|
||||
if (!map[key]) map[key] = []
|
||||
map[key].push(value)
|
||||
}
|
||||
|
||||
function nameFromN(value) {
|
||||
const parts = value.split(';')
|
||||
const family = (parts[0] || '').trim()
|
||||
const given = (parts[1] || '').trim()
|
||||
const additional = (parts[2] || '').trim()
|
||||
const suffix = (parts[4] || '').trim()
|
||||
const fromStructured = [given, additional, family, suffix].filter(Boolean).join(' ').trim()
|
||||
if (fromStructured) return fromStructured
|
||||
return parts.map((p) => p.trim()).filter(Boolean).join(' ').trim()
|
||||
}
|
||||
|
||||
function nameFromCard(props) {
|
||||
const fn = props.FN?.[0]
|
||||
if (fn) return fn
|
||||
const n = props.N?.[0]
|
||||
if (n) return nameFromN(n)
|
||||
return ''
|
||||
}
|
||||
|
||||
function first(props, key) {
|
||||
return props[key]?.[0] || ''
|
||||
}
|
||||
|
||||
function cardToRow(props) {
|
||||
const orgRaw = first(props, 'ORG')
|
||||
const orgParts = orgRaw.split(';').map((p) => p.trim()).filter(Boolean)
|
||||
const phones = (props.TEL || []).map(stripMailto).filter(Boolean)
|
||||
const emails = (props.EMAIL || []).map(stripMailto).filter(Boolean)
|
||||
const notes = [
|
||||
...(props.NOTE || []),
|
||||
...(props.ADR || []).map((adr) => adr.split(';').map((p) => p.trim()).filter(Boolean).join(', ')),
|
||||
].filter(Boolean)
|
||||
|
||||
return {
|
||||
name: nameFromCard(props),
|
||||
email: emails[0] || '',
|
||||
phone: phones[0] || '',
|
||||
organization: orgParts[0] || '',
|
||||
position: first(props, 'TITLE'),
|
||||
notes: notes.join('\n').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVcf(text) {
|
||||
const rows = []
|
||||
let props = null
|
||||
|
||||
for (const line of normalizeVcardLines(text)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
const upper = trimmed.toUpperCase()
|
||||
if (upper === 'BEGIN:VCARD') {
|
||||
props = {}
|
||||
continue
|
||||
}
|
||||
if (upper === 'END:VCARD') {
|
||||
if (props) rows.push(cardToRow(props))
|
||||
props = null
|
||||
continue
|
||||
}
|
||||
if (!props) continue
|
||||
|
||||
const colonIdx = trimmed.indexOf(':')
|
||||
if (colonIdx === -1) continue
|
||||
|
||||
const rawKey = trimmed.slice(0, colonIdx)
|
||||
const value = trimmed.slice(colonIdx + 1)
|
||||
const { name: key, params } = parsePropertyKey(rawKey)
|
||||
if (!key) continue
|
||||
|
||||
let decoded = decodePropertyValue(value, params)
|
||||
if (['EMAIL', 'TEL', 'URL'].includes(key)) {
|
||||
decoded = stripMailto(decoded)
|
||||
}
|
||||
pushValue(props, key, decoded)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseVcf } from './vcard'
|
||||
|
||||
describe('parseVcf', () => {
|
||||
it('parses a single vCard 3.0 contact', () => {
|
||||
const text = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Иван Иванов
|
||||
N:Иванов;Иван;;;
|
||||
EMAIL;TYPE=INTERNET:ivan@example.com
|
||||
TEL;TYPE=CELL:+79000000001
|
||||
ORG:ООО Ромашка;Отдел продаж
|
||||
TITLE:Директор
|
||||
NOTE:Знакомы с 2020
|
||||
END:VCARD`
|
||||
|
||||
const rows = parseVcf(text)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toEqual({
|
||||
name: 'Иван Иванов',
|
||||
email: 'ivan@example.com',
|
||||
phone: '+79000000001',
|
||||
organization: 'ООО Ромашка',
|
||||
position: 'Директор',
|
||||
notes: 'Знакомы с 2020',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses vCard 2.1 username-only contacts', () => {
|
||||
const text = `BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N:;pumpkinelena;;;
|
||||
FN:pumpkinelena
|
||||
END:VCARD`
|
||||
|
||||
const rows = parseVcf(text)
|
||||
expect(rows[0].name).toBe('pumpkinelena')
|
||||
})
|
||||
|
||||
it('decodes quoted-printable split across multiple lines', () => {
|
||||
const text = `BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=D0=90=D1=80=D1=82=D0=B5=D0=BC=20=D0=91=D0=BE=D1=80=D0=BE=D0=
|
||||
=B4=D0=B8=D0=BD
|
||||
END:VCARD`
|
||||
|
||||
const rows = parseVcf(text)
|
||||
expect(rows[0].name).toBe('Артем Бородин')
|
||||
})
|
||||
|
||||
it('decodes long quoted-printable FN with soft line breaks', () => {
|
||||
const text = `BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=D0=90=D0=B1=D1=80=D0=B0=D0=BC=D0=B5=D0=BD=D0=BA=D0=BE
|
||||
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:;=D0=90=D0=B1=D1=80=D0=B0=D0=BC=D0=B5=D0=BD=D0=BA=D0=BE;;;
|
||||
TEL;CELL:89314045999
|
||||
END:VCARD`
|
||||
|
||||
const rows = parseVcf(text)
|
||||
expect(rows[0].name).toBe('Абраменко')
|
||||
expect(rows[0].phone).toBe('89314045999')
|
||||
})
|
||||
|
||||
it('parses multiple vCards and unfolded NOTE lines', () => {
|
||||
const text = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Мария Сидорова
|
||||
EMAIL:maria@example.com
|
||||
NOTE:Первая строка
|
||||
вторая строка
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:Петров;Пётр;;;
|
||||
TEL:+79001112233
|
||||
END:VCARD`
|
||||
|
||||
const rows = parseVcf(text)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].name).toBe('Мария Сидорова')
|
||||
expect(rows[1].name).toBe('Пётр Петров')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
export function generateId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateId } from './uuid'
|
||||
|
||||
describe('generateId', () => {
|
||||
it('returns uuid-like string', () => {
|
||||
const id = generateId()
|
||||
expect(id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createContact as createContactUseCase,
|
||||
updateContact as updateContactUseCase,
|
||||
deleteContact as deleteContactUseCase,
|
||||
deleteContacts as deleteContactsUseCase,
|
||||
} from '../application/usecases/contacts'
|
||||
import {
|
||||
listRelations,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from '../infrastructure/repositories/repositoryFactory'
|
||||
import {
|
||||
importContactsFromFile,
|
||||
exportContacts as exportContactsUseCase,
|
||||
exportLocalData,
|
||||
importLocalDump,
|
||||
} from '../application/usecases/importExport'
|
||||
@@ -121,8 +123,25 @@ export const useContactsStore = defineStore('contacts', {
|
||||
async deleteContact(id) {
|
||||
return this.withLoading('contactsLoading', async () => {
|
||||
await deleteContactUseCase(id)
|
||||
this.contacts = this.contacts.filter((c) => c.id !== id)
|
||||
this.relations = this.relations.filter((r) => r.source !== id && r.target !== id)
|
||||
const sid = String(id)
|
||||
this.contacts = this.contacts.filter((c) => String(c.id) !== sid)
|
||||
this.relations = this.relations.filter(
|
||||
(r) => String(r.source) !== sid && String(r.target) !== sid
|
||||
)
|
||||
await syncPendingChanges()
|
||||
})
|
||||
},
|
||||
|
||||
async deleteContacts(ids = []) {
|
||||
return this.withLoading('contactsLoading', async () => {
|
||||
const uniqueIds = [...new Set(ids.map(String))]
|
||||
if (!uniqueIds.length) return
|
||||
await deleteContactsUseCase(uniqueIds)
|
||||
const idSet = new Set(uniqueIds)
|
||||
this.contacts = this.contacts.filter((c) => !idSet.has(String(c.id)))
|
||||
this.relations = this.relations.filter(
|
||||
(r) => !idSet.has(String(r.source)) && !idSet.has(String(r.target))
|
||||
)
|
||||
await syncPendingChanges()
|
||||
})
|
||||
},
|
||||
@@ -131,7 +150,15 @@ export const useContactsStore = defineStore('contacts', {
|
||||
return this.withLoading('relationsLoading', async () => {
|
||||
const data = await createRelationUseCase(payload)
|
||||
this.relations.push(data)
|
||||
await this.fetchContacts()
|
||||
const sid = String(data.source)
|
||||
const tid = String(data.target)
|
||||
this.contacts = this.contacts.map((c) => {
|
||||
const id = String(c.id)
|
||||
if (id === sid || id === tid) {
|
||||
return { ...c, relations_count: Number(c.relations_count || 0) + 1 }
|
||||
}
|
||||
return c
|
||||
})
|
||||
await syncPendingChanges()
|
||||
return data
|
||||
})
|
||||
@@ -155,6 +182,10 @@ export const useContactsStore = defineStore('contacts', {
|
||||
})
|
||||
},
|
||||
|
||||
async exportContacts(format = 'csv') {
|
||||
return exportContactsUseCase({ format })
|
||||
},
|
||||
|
||||
async exportData(passphrase = '') {
|
||||
return exportLocalData({ passphrase })
|
||||
},
|
||||
|
||||
@@ -2,16 +2,25 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Контакты</h2>
|
||||
<button class="btn btn-primary" @click="showCreate = true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Добавить
|
||||
</button>
|
||||
<div class="page-header__actions">
|
||||
<button
|
||||
v-if="selectedCount > 0"
|
||||
class="btn btn-danger"
|
||||
@click="confirmBulkDelete"
|
||||
>
|
||||
Удалить выбранные ({{ selectedCount }})
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="showCreate = true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Search -->
|
||||
<div v-if="store.error" class="alert alert-error">{{ store.error }}</div>
|
||||
<div class="search-bar">
|
||||
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
@@ -24,6 +33,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="linkSelectionCount === 1" class="alert alert-info link-hint">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
Удерживайте Ctrl (⌘ на Mac) и кликните по второму контакту.
|
||||
</div>
|
||||
<p v-else class="text-muted link-hint link-hint--static">
|
||||
Ctrl+клик (⌘+клик на Mac) по двум контактам — создать связь.
|
||||
</p>
|
||||
|
||||
<div v-if="store.loading" class="spinner"></div>
|
||||
<div v-else-if="store.contacts.length === 0" class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -36,6 +53,15 @@
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
:indeterminate="someSelected && !allSelected"
|
||||
aria-label="Выбрать все"
|
||||
@click.stop.prevent="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<th>Имя</th>
|
||||
<th>Организация</th>
|
||||
<th>Email</th>
|
||||
@@ -44,7 +70,23 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in store.contacts" :key="c.id" @click="goTo(c.id)">
|
||||
<tr
|
||||
v-for="c in store.contacts"
|
||||
:key="c.id"
|
||||
:class="{
|
||||
'is-selected': isSelected(c.id),
|
||||
'is-link-selected': isLinkSelected(c.id),
|
||||
}"
|
||||
@click="onRowClick(c, $event)"
|
||||
>
|
||||
<td class="col-check" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(c.id)"
|
||||
:aria-label="`Выбрать ${c.name}`"
|
||||
@click.stop.prevent="toggleSelect(c.id)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div style="font-weight:500;">{{ c.name }}</div>
|
||||
<div class="text-muted mt-1">{{ c.position }}</div>
|
||||
@@ -64,7 +106,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create modal -->
|
||||
<div v-if="showCreate" class="modal-overlay" @click.self="showCreate = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
@@ -75,7 +116,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<div v-if="editTarget" class="modal-overlay" @click.self="editTarget = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
@@ -86,7 +126,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirm -->
|
||||
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
@@ -95,18 +134,47 @@
|
||||
<p class="text-muted">Будет удалён контакт <strong style="color:var(--text)">{{ deleteTarget.name }}</strong> и все его связи.</p>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="deleteTarget = null">Отмена</button>
|
||||
<button class="btn btn-danger" @click="doDelete">Удалить</button>
|
||||
<button class="btn btn-danger" :disabled="deleting" @click="doDelete">
|
||||
{{ deleting ? 'Удаление...' : 'Удалить' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="bulkDeleteOpen" class="modal-overlay" @click.self="bulkDeleteOpen = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Удалить выбранные контакты?</h3>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
Будет удалено контактов: <strong style="color:var(--text)">{{ selectedCount }}</strong>
|
||||
и все их связи.
|
||||
</p>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="bulkDeleteOpen = false">Отмена</button>
|
||||
<button class="btn btn-danger" :disabled="bulkDeleting" @click="doBulkDelete">
|
||||
{{ bulkDeleting ? 'Удаление...' : 'Удалить' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateRelationModal
|
||||
:open="relationModalOpen"
|
||||
:source="relationPair?.[0]"
|
||||
:target="relationPair?.[1]"
|
||||
@close="closeRelationModal"
|
||||
@created="onRelationCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
import ContactForm from '../components/ContactForm.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
|
||||
const store = useContactsStore()
|
||||
const router = useRouter()
|
||||
@@ -114,11 +182,85 @@ const search = ref('')
|
||||
const showCreate = ref(false)
|
||||
const editTarget = ref(null)
|
||||
const deleteTarget = ref(null)
|
||||
const bulkDeleteOpen = ref(false)
|
||||
const bulkDeleting = ref(false)
|
||||
const deleting = ref(false)
|
||||
const selectedIds = ref([])
|
||||
const relationModalOpen = ref(false)
|
||||
const relationPair = ref(null)
|
||||
|
||||
const {
|
||||
linkSelection,
|
||||
linkSelectionCount,
|
||||
isLinkSelected,
|
||||
clearLinkSelection,
|
||||
handleCtrlPick,
|
||||
} = useCtrlLinkSelection({
|
||||
onPairSelected(pair) {
|
||||
relationPair.value = pair
|
||||
relationModalOpen.value = true
|
||||
},
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => selectedIds.value.length)
|
||||
|
||||
const allSelected = computed(() =>
|
||||
store.contacts.length > 0 && store.contacts.every((c) => isSelected(c.id))
|
||||
)
|
||||
|
||||
const someSelected = computed(() => selectedCount.value > 0)
|
||||
|
||||
function isSelected(id) {
|
||||
const sid = String(id)
|
||||
return selectedIds.value.includes(sid)
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
const sid = String(id)
|
||||
if (selectedIds.value.includes(sid)) {
|
||||
selectedIds.value = selectedIds.value.filter((item) => item !== sid)
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, sid]
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = []
|
||||
} else {
|
||||
selectedIds.value = store.contacts.map((c) => String(c.id))
|
||||
}
|
||||
}
|
||||
|
||||
function pruneSelection() {
|
||||
const visible = new Set(store.contacts.map((c) => String(c.id)))
|
||||
selectedIds.value = selectedIds.value.filter((id) => visible.has(id))
|
||||
}
|
||||
|
||||
let searchTimer = null
|
||||
function onSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => store.fetchContacts(search.value), 300)
|
||||
searchTimer = setTimeout(async () => {
|
||||
await store.fetchContacts(search.value)
|
||||
pruneSelection()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onRowClick(c, event) {
|
||||
if (handleCtrlPick(c, event)) return
|
||||
goTo(c.id)
|
||||
}
|
||||
|
||||
function closeRelationModal() {
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
clearLinkSelection()
|
||||
}
|
||||
|
||||
function onRelationCreated() {
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
clearLinkSelection()
|
||||
}
|
||||
|
||||
function goTo(id) { router.push(`/contacts/${id}`) }
|
||||
@@ -140,7 +282,59 @@ async function onUpdate(data) {
|
||||
function confirmDelete(c) { deleteTarget.value = c }
|
||||
|
||||
async function doDelete() {
|
||||
await store.deleteContact(deleteTarget.value.id)
|
||||
deleteTarget.value = null
|
||||
deleting.value = true
|
||||
try {
|
||||
await store.deleteContact(deleteTarget.value.id)
|
||||
selectedIds.value = selectedIds.value.filter(
|
||||
(id) => id !== String(deleteTarget.value.id)
|
||||
)
|
||||
deleteTarget.value = null
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
bulkDeleteOpen.value = true
|
||||
}
|
||||
|
||||
async function doBulkDelete() {
|
||||
bulkDeleting.value = true
|
||||
try {
|
||||
await store.deleteContacts([...selectedIds.value])
|
||||
selectedIds.value = []
|
||||
bulkDeleteOpen.value = false
|
||||
} finally {
|
||||
bulkDeleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.col-check {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.col-check input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.is-selected {
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
tr.is-link-selected {
|
||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--green);
|
||||
}
|
||||
.link-hint {
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.link-hint--static {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
/>
|
||||
|
||||
<div class="graph-view-toolbar">
|
||||
<p class="graph-link-hint text-muted">
|
||||
Ctrl+клик (⌘+клик) по двум узлам — создать связь.
|
||||
<span v-if="linkSelectionCount === 1">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
</span>
|
||||
</p>
|
||||
<RelationTypeFilters
|
||||
:relation-types="allRelationTypes"
|
||||
:active-values="activeFilters"
|
||||
@@ -61,18 +67,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GraphNodeContextMenu
|
||||
:open="contextMenuOpen"
|
||||
:node="contextMenuNode"
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
@close="closeContextMenu"
|
||||
@info="openNodeInfo"
|
||||
/>
|
||||
|
||||
<CreateRelationModal
|
||||
:open="relationModalOpen"
|
||||
:source="relationPair?.[0]"
|
||||
:target="relationPair?.[1]"
|
||||
@close="closeRelationModal"
|
||||
@created="onRelationCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||
import { clusterColor } from '../lib/graph/clusterColors'
|
||||
import { computeClusterMap } from '../lib/graph/clusters'
|
||||
import { RELATION_TYPES } from '../domain/networkChoices'
|
||||
import { fetchGraphBundle } from '../composables/useGraphData'
|
||||
import { edgeFromRelation } from '../application/usecases/graph'
|
||||
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
|
||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
|
||||
let themeObserver = null
|
||||
let detachContextHandler = null
|
||||
|
||||
const {
|
||||
contextMenuOpen,
|
||||
contextMenuNode,
|
||||
contextMenuX,
|
||||
contextMenuY,
|
||||
closeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
} = useGraphNodeContextMenu()
|
||||
|
||||
function openNodeInfo(node) {
|
||||
selectedNode.value = node || null
|
||||
}
|
||||
|
||||
const store = useContactsStore()
|
||||
const graphContainer = ref(null)
|
||||
@@ -80,6 +127,20 @@ const loading = ref(true)
|
||||
const network = ref(null)
|
||||
const physicsEnabled = ref(true)
|
||||
const selectedNode = ref(null)
|
||||
const relationModalOpen = ref(false)
|
||||
const relationPair = ref(null)
|
||||
|
||||
const {
|
||||
linkSelection,
|
||||
linkSelectionCount,
|
||||
clearLinkSelection,
|
||||
handleCtrlPickNode,
|
||||
} = useCtrlLinkSelection({
|
||||
onPairSelected(pair) {
|
||||
relationPair.value = pair
|
||||
relationModalOpen.value = true
|
||||
},
|
||||
})
|
||||
|
||||
let nodesDS = null
|
||||
let edgesDS = null
|
||||
@@ -87,12 +148,14 @@ let initRetryCount = 0
|
||||
const INIT_RETRY_MAX = 40
|
||||
let initRetryTimer = null
|
||||
let resizeObserver = null
|
||||
let themeObserver = null
|
||||
|
||||
const nodes = ref([])
|
||||
const edges = ref([])
|
||||
const allRelationTypes = ref([])
|
||||
const activeFilters = ref([])
|
||||
const clusterMap = ref(new Map())
|
||||
|
||||
const relationTypeLabels = Object.fromEntries(RELATION_TYPES.map((r) => [r.value, r.label]))
|
||||
|
||||
const selectedContact = computed(() =>
|
||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||
@@ -107,6 +170,7 @@ function graphPalette() {
|
||||
return {
|
||||
nodeBackground: cssVar('--surface-alt', '#22263a'),
|
||||
nodeBorder: cssVar('--accent', '#5b8dee'),
|
||||
nodeLinkBorder: cssVar('--green', '#4ecca3'),
|
||||
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
|
||||
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
|
||||
nodeFont: cssVar('--text', '#e2e6f3'),
|
||||
@@ -114,6 +178,130 @@ function graphPalette() {
|
||||
}
|
||||
}
|
||||
|
||||
function recomputeClusters() {
|
||||
clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
|
||||
}
|
||||
|
||||
function nodeDegree(id) {
|
||||
const sid = String(id)
|
||||
return filteredEdges().filter(
|
||||
(e) => String(e.from) === sid || String(e.to) === sid
|
||||
).length
|
||||
}
|
||||
|
||||
function isDarkTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
}
|
||||
|
||||
function nodeVisColor(nodeId, linkIds) {
|
||||
const sid = String(nodeId)
|
||||
const palette = graphPalette()
|
||||
if (linkIds.has(sid)) {
|
||||
return {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeLinkBorder,
|
||||
highlight: {
|
||||
background: palette.nodeHighlightBackground,
|
||||
border: palette.nodeHighlightBorder,
|
||||
},
|
||||
}
|
||||
}
|
||||
const cc = clusterColor(clusterMap.value.get(sid), isDarkTheme())
|
||||
return {
|
||||
background: cc.bg,
|
||||
border: cc.border,
|
||||
highlight: { background: cc.highlight, border: cc.border },
|
||||
}
|
||||
}
|
||||
|
||||
function mapGraphNodeToVis(n, linkIds = new Set()) {
|
||||
const palette = graphPalette()
|
||||
const degree = nodeDegree(n.id)
|
||||
return {
|
||||
id: String(n.id),
|
||||
label: n.label || String(n.id),
|
||||
title: n.title,
|
||||
color: nodeVisColor(n.id, linkIds),
|
||||
font: { color: palette.nodeFont, size: degree > 0 ? 13 : 11 },
|
||||
shape: 'dot',
|
||||
size: degree > 0 ? 12 + Math.min(degree, 6) * 2 : 9,
|
||||
borderWidth: linkIds.has(String(n.id)) ? 3 : 2,
|
||||
}
|
||||
}
|
||||
|
||||
function refreshNodeStyles() {
|
||||
if (!nodesDS) return
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
nodesDS.update(nodes.value.map((n) => mapGraphNodeToVis(n, linkIds)))
|
||||
network.value?.redraw()
|
||||
}
|
||||
|
||||
function applyLinkHighlights() {
|
||||
refreshNodeStyles()
|
||||
}
|
||||
|
||||
function mapGraphEdgeToVis(e) {
|
||||
const palette = graphPalette()
|
||||
const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
|
||||
const typeLabel = relationTypeLabels[e.relation_type] || e.relation_type
|
||||
const tip = [typeLabel, e.title !== e.relation_type ? e.title : ''].filter(Boolean).join(' — ')
|
||||
return {
|
||||
id: String(e.id),
|
||||
from: String(e.from),
|
||||
to: String(e.to),
|
||||
title: tip || typeLabel,
|
||||
relation_type: e.relation_type,
|
||||
color: { color: rc.color, highlight: rc.highlight, opacity: 0.8 },
|
||||
width: e.interaction_intensity === 'sparse' ? 1 : 2,
|
||||
dashes: e.interaction_intensity === 'sparse' ? [6, 4] : false,
|
||||
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'dynamic' },
|
||||
}
|
||||
}
|
||||
|
||||
function appendRelationEdge(relation) {
|
||||
if (!relation) return
|
||||
const edge = edgeFromRelation(relation)
|
||||
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
|
||||
edges.value.push(edge)
|
||||
}
|
||||
if (!edgesDS) return
|
||||
|
||||
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
|
||||
if (
|
||||
activeFilters.value.length < allRelationTypes.value.length &&
|
||||
!activeFilters.value.includes(edge.relation_type)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!edgesDS.get(String(edge.id))) {
|
||||
edgesDS.add(mapGraphEdgeToVis(edge))
|
||||
}
|
||||
recomputeClusters()
|
||||
refreshNodeStyles()
|
||||
}
|
||||
|
||||
function closeRelationModal() {
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
clearLinkSelection()
|
||||
applyLinkHighlights()
|
||||
}
|
||||
|
||||
function onRelationCreated(relation) {
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
clearLinkSelection()
|
||||
applyLinkHighlights()
|
||||
appendRelationEdge(relation)
|
||||
}
|
||||
|
||||
watch(linkSelection, () => {
|
||||
applyLinkHighlights()
|
||||
}, { deep: true })
|
||||
|
||||
async function loadGraph() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -127,6 +315,7 @@ async function loadGraph() {
|
||||
}
|
||||
// Контейнер #graph-container в DOM только когда loading=false и nodes.length > 0
|
||||
if (nodes.value.length > 0) {
|
||||
recomputeClusters()
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
initNetwork()
|
||||
@@ -165,33 +354,9 @@ function initNetwork() {
|
||||
initRetryTimer = null
|
||||
}
|
||||
|
||||
const palette = graphPalette()
|
||||
const nodeList = nodes.value.map((n) => ({
|
||||
id: String(n.id),
|
||||
label: n.label || String(n.id),
|
||||
title: n.title,
|
||||
...(n.group != null && { group: n.group }),
|
||||
color: {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeBorder,
|
||||
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
|
||||
},
|
||||
font: { color: palette.nodeFont, size: 13 },
|
||||
shape: 'dot',
|
||||
size: 14,
|
||||
}))
|
||||
const edgeList = filteredEdges().map((e) => ({
|
||||
id: String(e.id),
|
||||
from: String(e.from),
|
||||
to: String(e.to),
|
||||
label: e.label,
|
||||
title: e.title,
|
||||
relation_type: e.relation_type,
|
||||
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
|
||||
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'curvedCW', roundness: 0.1 },
|
||||
}))
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
const nodeList = nodes.value.map((n) => mapGraphNodeToVis(n, linkIds))
|
||||
const edgeList = filteredEdges().map(mapGraphEdgeToVis)
|
||||
nodesDS = new DataSet(nodeList)
|
||||
edgesDS = new DataSet(edgeList)
|
||||
|
||||
@@ -201,27 +366,43 @@ function initNetwork() {
|
||||
{
|
||||
physics: {
|
||||
enabled: true,
|
||||
stabilization: { iterations: 150 },
|
||||
barnesHut: { gravitationalConstant: -3000, springLength: 180 },
|
||||
stabilization: { iterations: 250, fit: true },
|
||||
barnesHut: {
|
||||
gravitationalConstant: -12000,
|
||||
centralGravity: 0.15,
|
||||
springLength: 220,
|
||||
springConstant: 0.035,
|
||||
damping: 0.12,
|
||||
avoidOverlap: 0.25,
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
tooltipDelay: 200,
|
||||
hover: true,
|
||||
hideEdgesOnDrag: true,
|
||||
zoomView: true,
|
||||
dragView: true,
|
||||
dragNodes: true,
|
||||
},
|
||||
nodes: { borderWidth: 1.5 },
|
||||
}
|
||||
)
|
||||
|
||||
network.value.on('click', (params) => {
|
||||
closeContextMenu()
|
||||
if (params.nodes.length > 0) {
|
||||
const id = params.nodes[0]
|
||||
const node = nodes.value.find((n) => String(n.id) === id)
|
||||
selectedNode.value = node || null
|
||||
const domEvent = params.event?.srcEvent || params.event
|
||||
if (domEvent && (domEvent.ctrlKey || domEvent.metaKey)) {
|
||||
handleCtrlPickNode(node, domEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
detachContextHandler?.()
|
||||
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value)
|
||||
|
||||
network.value.once('stabilizationIterationsDone', () => {
|
||||
network.value?.fit({ animation: { duration: 400 } })
|
||||
setTimeout(() => {
|
||||
@@ -250,34 +431,17 @@ function toggleFilter(type) {
|
||||
activeFilters.value.push(type)
|
||||
}
|
||||
if (edgesDS) {
|
||||
const palette = graphPalette()
|
||||
recomputeClusters()
|
||||
edgesDS.clear()
|
||||
edgesDS.add(
|
||||
filteredEdges().map((e) => ({
|
||||
...e,
|
||||
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
|
||||
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'curvedCW', roundness: 0.1 },
|
||||
}))
|
||||
)
|
||||
edgesDS.add(filteredEdges().map(mapGraphEdgeToVis))
|
||||
refreshNodeStyles()
|
||||
}
|
||||
}
|
||||
|
||||
function applyThemeToNetwork() {
|
||||
if (!nodesDS || !edgesDS || !network.value) return
|
||||
refreshNodeStyles()
|
||||
const palette = graphPalette()
|
||||
nodesDS.update(
|
||||
nodes.value.map((n) => ({
|
||||
id: String(n.id),
|
||||
color: {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeBorder,
|
||||
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
|
||||
},
|
||||
font: { color: palette.nodeFont, size: 13 },
|
||||
}))
|
||||
)
|
||||
edgesDS.update(
|
||||
filteredEdges().map((e) => ({
|
||||
id: String(e.id),
|
||||
@@ -303,6 +467,8 @@ onMounted(() => {
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (initRetryTimer) clearTimeout(initRetryTimer)
|
||||
detachContextHandler?.()
|
||||
closeContextMenu()
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
network.value?.destroy()
|
||||
@@ -321,6 +487,10 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28px 10px;
|
||||
}
|
||||
.graph-link-hint {
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.graph-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Импорт контактов</h2>
|
||||
<h2>Импорт и экспорт</h2>
|
||||
</div>
|
||||
<div class="page-content content-narrow">
|
||||
<div class="card">
|
||||
<h3 class="section-title">Загрузить файл</h3>
|
||||
<p class="text-muted section-subtitle">
|
||||
Поддерживаются форматы <strong style="color:var(--text)">CSV</strong> и <strong style="color:var(--text)">JSON</strong>.
|
||||
Поддерживаются форматы <strong style="color:var(--text)">CSV</strong>, <strong style="color:var(--text)">JSON</strong> и <strong style="color:var(--text)">vCard (.vcf)</strong>.
|
||||
</p>
|
||||
|
||||
<!-- Format examples -->
|
||||
@@ -17,6 +17,13 @@
|
||||
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример JSON:</div>
|
||||
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">[{"name":"Иван Иванов","email":"ivan@example.com","organization":"ООО Ромашка"}]</pre>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример vCard (.vcf):</div>
|
||||
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">BEGIN:VCARD
|
||||
FN:Иван Иванов
|
||||
EMAIL:ivan@example.com
|
||||
TEL:+79000000001
|
||||
ORG:ООО Ромашка
|
||||
END:VCARD</pre>
|
||||
</div>
|
||||
|
||||
<!-- Drop zone -->
|
||||
@@ -36,7 +43,7 @@
|
||||
<div style="font-size:13px;color:var(--text-muted);">
|
||||
{{ selectedFile ? selectedFile.name : 'Перетащите файл или нажмите для выбора' }}
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept=".csv,.json" style="display:none" @change="onFileSelect" />
|
||||
<input ref="fileInput" type="file" accept=".csv,.json,.vcf,.vcard" style="display:none" @change="onFileSelect" />
|
||||
</div>
|
||||
|
||||
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:16px;">
|
||||
@@ -67,6 +74,41 @@
|
||||
<button v-if="selectedFile" class="btn btn-secondary" style="margin-left:8px;" @click="reset">Сбросить</button>
|
||||
</div>
|
||||
|
||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border);" />
|
||||
<h3 class="section-title">Экспорт контактов</h3>
|
||||
<p class="text-muted section-subtitle">
|
||||
Скачать все контакты в выбранном формате: <strong style="color:var(--text)">CSV</strong>,
|
||||
<strong style="color:var(--text)">JSON</strong> или <strong style="color:var(--text)">vCard (.vcf)</strong>
|
||||
— совместимо с Nextcloud и другими адресными книгами.
|
||||
</p>
|
||||
<div class="export-row">
|
||||
<div class="form-group" style="margin-bottom:0;flex:1;">
|
||||
<label for="export-format">Формат файла</label>
|
||||
<select id="export-format" v-model="exportFormat" class="form-control">
|
||||
<option value="csv">CSV (.csv)</option>
|
||||
<option value="json">JSON (.json)</option>
|
||||
<option value="vcf">vCard (.vcf)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="exporting || store.totalContacts === 0"
|
||||
@click="doExportContacts"
|
||||
>
|
||||
{{ exporting ? 'Экспорт...' : 'Экспортировать' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="store.totalContacts === 0" class="text-muted" style="font-size:12px;margin-top:8px;">
|
||||
Нет контактов для экспорта.
|
||||
</p>
|
||||
<div v-if="exportResult" class="alert" :class="exportResult.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
|
||||
<span v-if="exportResult.error">{{ exportResult.error }}</span>
|
||||
<span v-else>
|
||||
Экспортировано контактов: <strong>{{ exportResult.count }}</strong>
|
||||
({{ exportResult.formatLabel }}).
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border);" />
|
||||
<h3 class="section-title">Бэкап локальной базы</h3>
|
||||
<p class="text-muted section-subtitle">
|
||||
@@ -108,6 +150,15 @@ const importing = ref(false)
|
||||
const result = ref(null)
|
||||
const backupPassphrase = ref('')
|
||||
const busyBackup = ref(false)
|
||||
const exportFormat = ref('csv')
|
||||
const exporting = ref(false)
|
||||
const exportResult = ref(null)
|
||||
|
||||
const exportFormatLabels = {
|
||||
csv: 'CSV',
|
||||
json: 'JSON',
|
||||
vcf: 'vCard',
|
||||
}
|
||||
|
||||
function onFileSelect(e) {
|
||||
selectedFile.value = e.target.files[0] || null
|
||||
@@ -139,6 +190,28 @@ function reset() {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
async function doExportContacts() {
|
||||
exporting.value = true
|
||||
exportResult.value = null
|
||||
try {
|
||||
const { blob, filename, count, format } = await store.exportContacts(exportFormat.value)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
exportResult.value = {
|
||||
count,
|
||||
formatLabel: exportFormatLabels[format] || format,
|
||||
}
|
||||
} catch (e) {
|
||||
exportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
busyBackup.value = true
|
||||
try {
|
||||
@@ -199,5 +272,10 @@ async function onBackupFileSelect(e) {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.export-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
</NetworkMapTopPanel>
|
||||
|
||||
<div class="network-map-body">
|
||||
<p v-if="!loading && nodes.length > 0" class="map-link-hint text-muted">
|
||||
Ctrl+клик (⌘+клик) по двум контактам — создать связь.
|
||||
<span v-if="linkSelectionCount === 1">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
</span>
|
||||
</p>
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card">
|
||||
<p>
|
||||
@@ -54,14 +60,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GraphNodeContextMenu
|
||||
:open="contextMenuOpen"
|
||||
:node="contextMenuNode"
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
@close="closeContextMenu"
|
||||
@info="openNodeInfo"
|
||||
/>
|
||||
|
||||
<CreateRelationModal
|
||||
:open="relationModalOpen"
|
||||
:source="relationPair?.[0]"
|
||||
:target="relationPair?.[1]"
|
||||
@close="closeRelationModal"
|
||||
@created="onRelationCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||
import {
|
||||
SPHERE_ORDER,
|
||||
@@ -72,8 +96,28 @@ import {
|
||||
nodeXY,
|
||||
} from '../lib/map/positioning'
|
||||
import { fetchGraphBundle, fetchMapChoices } from '../composables/useGraphData'
|
||||
import { edgeFromRelation } from '../application/usecases/graph'
|
||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
let themeObserver = null
|
||||
let detachContextHandler = null
|
||||
|
||||
const {
|
||||
contextMenuOpen,
|
||||
contextMenuNode,
|
||||
contextMenuX,
|
||||
contextMenuY,
|
||||
closeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
} = useGraphNodeContextMenu()
|
||||
|
||||
function openNodeInfo(node) {
|
||||
selectedNode.value = node || null
|
||||
}
|
||||
|
||||
const GRID_SCALE = 5
|
||||
|
||||
function nodeById(id) {
|
||||
@@ -183,6 +227,21 @@ const graphContainer = ref(null)
|
||||
const loading = ref(true)
|
||||
const network = ref(null)
|
||||
const selectedNode = ref(null)
|
||||
const relationModalOpen = ref(false)
|
||||
const relationPair = ref(null)
|
||||
|
||||
const {
|
||||
linkSelection,
|
||||
linkSelectionCount,
|
||||
clearLinkSelection,
|
||||
handleCtrlPickNode,
|
||||
} = useCtrlLinkSelection({
|
||||
onPairSelected(pair) {
|
||||
relationPair.value = pair
|
||||
relationModalOpen.value = true
|
||||
},
|
||||
})
|
||||
|
||||
const nodes = ref([])
|
||||
const edges = ref([])
|
||||
const allRelationTypes = ref([])
|
||||
@@ -197,7 +256,6 @@ let resizeObserver = null
|
||||
let initRetryTimer = null
|
||||
let initRetryCount = 0
|
||||
const INIT_RETRY_MAX = 40
|
||||
let themeObserver = null
|
||||
|
||||
const selectedContact = computed(() =>
|
||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||
@@ -213,6 +271,7 @@ function mapPalette() {
|
||||
return {
|
||||
nodeBackground: cssVar('--surface-alt', '#22263a'),
|
||||
nodeBorder: cssVar('--accent', '#5b8dee'),
|
||||
nodeLinkBorder: cssVar('--green', '#4ecca3'),
|
||||
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
|
||||
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
|
||||
nodeFont: cssVar('--text', '#e2e6f3'),
|
||||
@@ -220,6 +279,67 @@ function mapPalette() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyLinkHighlights() {
|
||||
if (!nodesDS) return
|
||||
const palette = mapPalette()
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
nodesDS.update(
|
||||
nodes.value.map((n) => ({
|
||||
id: String(n.id),
|
||||
color: {
|
||||
background: palette.nodeBackground,
|
||||
border: linkIds.has(String(n.id)) ? palette.nodeLinkBorder : palette.nodeBorder,
|
||||
highlight: {
|
||||
background: palette.nodeHighlightBackground,
|
||||
border: palette.nodeHighlightBorder,
|
||||
},
|
||||
},
|
||||
borderWidth: linkIds.has(String(n.id)) ? 3 : 1.5,
|
||||
}))
|
||||
)
|
||||
network.value?.redraw()
|
||||
}
|
||||
|
||||
function appendRelationEdge(relation) {
|
||||
if (!relation) return
|
||||
const edge = edgeFromRelation(relation)
|
||||
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
|
||||
edges.value.push(edge)
|
||||
}
|
||||
if (!edgesDS) return
|
||||
|
||||
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
|
||||
if (
|
||||
activeFilters.value.length < allRelationTypes.value.length &&
|
||||
!activeFilters.value.includes(edge.relation_type)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!edgesDS.get(String(edge.id))) {
|
||||
edgesDS.add(mapEdgeToVis(edge))
|
||||
}
|
||||
}
|
||||
|
||||
function closeRelationModal() {
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
clearLinkSelection()
|
||||
applyLinkHighlights()
|
||||
}
|
||||
|
||||
function onRelationCreated(relation) {
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
clearLinkSelection()
|
||||
applyLinkHighlights()
|
||||
appendRelationEdge(relation)
|
||||
}
|
||||
|
||||
watch(linkSelection, () => {
|
||||
applyLinkHighlights()
|
||||
}, { deep: true })
|
||||
|
||||
/** Сетка в тех же мировых координатах, что и узлы (см. vis-network beforeDrawing после translate+scale). */
|
||||
function drawPolarGuide(ctx) {
|
||||
const palette = mapPalette()
|
||||
@@ -419,13 +539,20 @@ function initNetwork() {
|
||||
network.value.on('beforeDrawing', drawPolarGuide)
|
||||
|
||||
network.value.on('click', (params) => {
|
||||
closeContextMenu()
|
||||
if (params.nodes.length > 0) {
|
||||
const id = params.nodes[0]
|
||||
const node = nodes.value.find((n) => String(n.id) === id)
|
||||
selectedNode.value = node || null
|
||||
const domEvent = params.event?.srcEvent || params.event
|
||||
if (domEvent && (domEvent.ctrlKey || domEvent.metaKey)) {
|
||||
handleCtrlPickNode(node, domEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
detachContextHandler?.()
|
||||
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value)
|
||||
|
||||
network.value.on('dragEnd', async (params) => {
|
||||
if (!params.nodes?.length) return
|
||||
const id = params.nodes[0]
|
||||
@@ -483,15 +610,11 @@ function refreshLabelsByZoom() {
|
||||
|
||||
function applyThemeToNetwork() {
|
||||
if (!nodesDS || !network.value) return
|
||||
applyLinkHighlights()
|
||||
const palette = mapPalette()
|
||||
nodesDS.update(
|
||||
nodes.value.map((n) => ({
|
||||
id: String(n.id),
|
||||
color: {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeBorder,
|
||||
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
|
||||
},
|
||||
font: { color: palette.nodeFont, size: 11 },
|
||||
}))
|
||||
)
|
||||
@@ -563,6 +686,8 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
if (initRetryTimer) clearTimeout(initRetryTimer)
|
||||
detachContextHandler?.()
|
||||
closeContextMenu()
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
network.value?.destroy()
|
||||
@@ -585,6 +710,11 @@ onUnmounted(() => {
|
||||
padding: 0 28px 20px;
|
||||
position: relative;
|
||||
}
|
||||
.map-link-hint {
|
||||
font-size: 12px;
|
||||
margin: 10px 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.map-stack {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
||||
Reference in New Issue
Block a user