Add plugin platform with modular backend and frontend registry.
Split graph/import/meta into Django apps, add API v1 with OpenAPI and pytest, and introduce plugin registry with the tags reference plugin on both FE and BE. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Enabled frontend plugins (comma-separated)
|
||||
VITE_ENABLED_PLUGINS=tags
|
||||
@@ -48,6 +48,19 @@
|
||||
</svg>
|
||||
<span class="nav-label">Импорт</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-for="item in pluginNavItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="nav-link"
|
||||
active-class="active"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"/>
|
||||
</svg>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<button
|
||||
class="nav-link nav-theme-btn"
|
||||
@@ -86,8 +99,10 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { useContactsStore } from './stores/contacts'
|
||||
import { getPluginNavItems } from './core/pluginRegistry'
|
||||
|
||||
const store = useContactsStore()
|
||||
const pluginNavItems = getPluginNavItems()
|
||||
const THEME_KEY = 'ui-theme'
|
||||
const currentTheme = ref('dark')
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { tagRepository } from '../../plugins/tags/tagRepository.local'
|
||||
|
||||
/**
|
||||
* Persist plugin form payload after contact create/update.
|
||||
* @param {string|number} contactId
|
||||
* @param {{ tags?: string[] }} pluginPayload
|
||||
*/
|
||||
export async function saveContactPluginData(contactId, pluginPayload = {}) {
|
||||
if (!contactId || !pluginPayload) return
|
||||
if (Array.isArray(pluginPayload.tags)) {
|
||||
await tagRepository.setContactTags(contactId, pluginPayload.tags)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { getGraphBundle } from '../usecases/graph'
|
||||
import { getRelationTypes } from '../../infrastructure/repositories/repositoryFactory'
|
||||
import { isLocalMode } from '../../infrastructure/config/dataMode'
|
||||
import api from '../../api'
|
||||
import { applyGraphExtensions } from '../../core/pluginRegistry'
|
||||
|
||||
/**
|
||||
* Unified graph data access for GraphView and NetworkMapView.
|
||||
*/
|
||||
export async function fetchGraphBundle({ mapId = null } = {}) {
|
||||
let bundle
|
||||
if (isLocalMode()) {
|
||||
bundle = await getGraphBundle({ mapId })
|
||||
} else {
|
||||
const endpoint = mapId
|
||||
? `/network-map-graph/?map_id=${encodeURIComponent(mapId)}`
|
||||
: '/graph/'
|
||||
const [gRes, rtRes] = await Promise.all([
|
||||
api.get(endpoint),
|
||||
api.get('/relation-types/'),
|
||||
])
|
||||
bundle = {
|
||||
nodes: gRes.data.nodes || [],
|
||||
edges: gRes.data.edges || [],
|
||||
relationTypes: rtRes.data || [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...bundle,
|
||||
nodes: (bundle.nodes || []).map((n) => applyGraphExtensions('node', n)),
|
||||
edges: (bundle.edges || []).map((e) => applyGraphExtensions('edge', e)),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGraphBundleFromStore(store, { mapId = null } = {}) {
|
||||
const { buildGraphFromStore } = await import('../usecases/graph')
|
||||
if (!store.contacts.length) await store.fetchContacts()
|
||||
if (!store.relations.length) await store.fetchRelations()
|
||||
const relationTypes = store.relationTypes.length
|
||||
? store.relationTypes
|
||||
: await store.fetchRelationTypes()
|
||||
|
||||
const bundle = buildGraphFromStore(store.contacts, store.relations, relationTypes)
|
||||
if (mapId) {
|
||||
return fetchGraphBundle({ mapId })
|
||||
}
|
||||
return {
|
||||
...bundle,
|
||||
nodes: bundle.nodes.map((n) => applyGraphExtensions('node', n)),
|
||||
edges: bundle.edges.map((e) => applyGraphExtensions('edge', e)),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRelationTypes() {
|
||||
return getRelationTypes()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../infrastructure/config/dataMode', () => ({
|
||||
isLocalMode: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('../../core/pluginRegistry', () => ({
|
||||
applyGraphExtensions: (_kind, payload) => payload,
|
||||
}))
|
||||
|
||||
vi.mock('../usecases/graph', () => ({
|
||||
getGraphBundle: vi.fn(async () => ({
|
||||
nodes: [{ id: '1', label: 'A' }],
|
||||
edges: [],
|
||||
relationTypes: [],
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('graphDataService', () => {
|
||||
it('fetchGraphBundle returns nodes from local use case', async () => {
|
||||
const { fetchGraphBundle } = await import('./graphDataService.js')
|
||||
const bundle = await fetchGraphBundle()
|
||||
expect(bundle.nodes).toHaveLength(1)
|
||||
expect(bundle.nodes[0].label).toBe('A')
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,10 @@ vi.mock('../application/usecases/networkMaps', () => ({
|
||||
listMembershipsByContact: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
vi.mock('../core/pluginRegistry', () => ({
|
||||
getContactFormExtensions: () => [],
|
||||
}))
|
||||
|
||||
describe('ContactForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -21,10 +25,11 @@ describe('ContactForm', () => {
|
||||
it('emits submit with contact data and map ids', async () => {
|
||||
const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } })
|
||||
await wrapper.get('form').trigger('submit.prevent')
|
||||
const [contactData, mapIds] = wrapper.emitted('submit')[0]
|
||||
const [contactData, mapIds, pluginPayload] = wrapper.emitted('submit')[0]
|
||||
expect(contactData.name).toBe('Иван')
|
||||
expect(contactData.include_on_network_map).toBeUndefined()
|
||||
expect(mapIds).toEqual([])
|
||||
expect(pluginPayload).toEqual({ tags: [] })
|
||||
})
|
||||
|
||||
it('shows delete button when editing existing contact', async () => {
|
||||
|
||||
@@ -45,6 +45,13 @@
|
||||
<label>Заметки</label>
|
||||
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
||||
</div>
|
||||
<component
|
||||
:is="Ext"
|
||||
v-for="(Ext, index) in contactFormExtensions"
|
||||
:key="index"
|
||||
:contact-id="initial?.id"
|
||||
v-model="pluginTags"
|
||||
/>
|
||||
<div class="contact-form-footer">
|
||||
<button
|
||||
v-if="showDelete"
|
||||
@@ -63,9 +70,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch, computed, onMounted } from 'vue'
|
||||
import { reactive, ref, watch, computed, onMounted } from 'vue'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { listMembershipsByContact } from '../application/usecases/networkMaps'
|
||||
import { getContactFormExtensions } from '../core/pluginRegistry'
|
||||
|
||||
const props = defineProps({
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
@@ -75,6 +83,8 @@ const props = defineProps({
|
||||
const emit = defineEmits(['submit', 'cancel', 'delete'])
|
||||
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const contactFormExtensions = getContactFormExtensions()
|
||||
const pluginTags = ref([])
|
||||
|
||||
const showDelete = computed(() => {
|
||||
if (props.deletable) return true
|
||||
@@ -140,7 +150,7 @@ onMounted(async () => {
|
||||
|
||||
function onSubmit() {
|
||||
const { mapIds, ...contactData } = form
|
||||
emit('submit', contactData, mapIds)
|
||||
emit('submit', contactData, mapIds, { tags: [...pluginTags.value] })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,29 +1,10 @@
|
||||
import { getGraphBundle, getMapChoices } from '../application/usecases/graph'
|
||||
import { isLocalMode } from '../infrastructure/config/dataMode'
|
||||
import api from '../api'
|
||||
/**
|
||||
* Re-export for backward compatibility.
|
||||
* @deprecated Use application/services/graphDataService.js
|
||||
*/
|
||||
export {
|
||||
fetchGraphBundle,
|
||||
fetchRelationTypes,
|
||||
} from '../application/services/graphDataService'
|
||||
|
||||
export async function fetchGraphBundle({ graphEndpoint = '/graph/', mapId = null } = {}) {
|
||||
if (isLocalMode()) {
|
||||
return getGraphBundle({ mapId })
|
||||
}
|
||||
const endpoint = mapId
|
||||
? `/network-map-graph/?map_id=${encodeURIComponent(mapId)}`
|
||||
: graphEndpoint
|
||||
const [gRes, rtRes] = await Promise.all([
|
||||
api.get(endpoint),
|
||||
api.get('/relation-types/'),
|
||||
])
|
||||
return {
|
||||
nodes: gRes.data.nodes || [],
|
||||
edges: gRes.data.edges || [],
|
||||
relationTypes: rtRes.data || [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMapChoices() {
|
||||
if (isLocalMode()) {
|
||||
return getMapChoices()
|
||||
}
|
||||
const { data } = await api.get('/network-map-choices/')
|
||||
return data
|
||||
}
|
||||
export { getMapChoices as fetchMapChoices } from '../application/usecases/graph'
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { ENABLED_PLUGINS } from './config/enabledPlugins'
|
||||
|
||||
/**
|
||||
* Load and register enabled plugins at app bootstrap.
|
||||
*/
|
||||
export async function bootstrapPlugins() {
|
||||
const loaders = {
|
||||
tags: () => import('../plugins/tags/index.js'),
|
||||
}
|
||||
|
||||
for (const id of ENABLED_PLUGINS) {
|
||||
const load = loaders[id]
|
||||
if (!load) {
|
||||
console.warn(`[plugins] Unknown plugin "${id}" — skipped`)
|
||||
continue
|
||||
}
|
||||
await load()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Build-time list of enabled frontend plugins.
|
||||
* Override via VITE_ENABLED_PLUGINS=tags,birthdays
|
||||
*/
|
||||
const raw = import.meta.env.VITE_ENABLED_PLUGINS || 'tags'
|
||||
|
||||
export const ENABLED_PLUGINS = raw
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
export const CORE_VERSION = '1.0.0'
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Plugin registry for the Social Graph platform.
|
||||
* Plugins register routes, nav items, form extensions, Dexie upgrades, and sync contributors.
|
||||
*/
|
||||
|
||||
const plugins = new Map()
|
||||
const navItems = []
|
||||
const contactFormExtensions = []
|
||||
const graphToolbarActions = []
|
||||
const dexieUpgraders = []
|
||||
const syncContributors = []
|
||||
|
||||
/**
|
||||
* @typedef {object} PluginDefinition
|
||||
* @property {string} id
|
||||
* @property {string} [version]
|
||||
* @property {string} [minCoreVersion]
|
||||
* @property {string[]} [permissions]
|
||||
* @property {import('vue-router').RouteRecordRaw[]} [routes]
|
||||
* @property {object[]} [navItems] - { to, label, icon? }
|
||||
* @property {import('vue').Component[]} [contactFormExtensions]
|
||||
* @property {object[]} [graphToolbarActions] - { id, label, onClick }
|
||||
* @property {(db: import('dexie').Dexie) => void} [upgradeDexie]
|
||||
* @property {object} [syncContributor] - { entityType, push, pull }
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {PluginDefinition} definition
|
||||
*/
|
||||
export function registerPlugin(definition) {
|
||||
if (!definition?.id) {
|
||||
throw new Error('Plugin must have an id')
|
||||
}
|
||||
plugins.set(definition.id, definition)
|
||||
|
||||
if (definition.routes?.length) {
|
||||
definition.routes.forEach((route) => {
|
||||
if (!route.meta) route.meta = {}
|
||||
route.meta.pluginId = definition.id
|
||||
})
|
||||
}
|
||||
if (definition.navItems?.length) {
|
||||
navItems.push(...definition.navItems.map((item) => ({ ...item, pluginId: definition.id })))
|
||||
}
|
||||
if (definition.contactFormExtensions?.length) {
|
||||
contactFormExtensions.push(...definition.contactFormExtensions)
|
||||
}
|
||||
if (definition.graphToolbarActions?.length) {
|
||||
graphToolbarActions.push(...definition.graphToolbarActions)
|
||||
}
|
||||
if (typeof definition.upgradeDexie === 'function') {
|
||||
dexieUpgraders.push(definition.upgradeDexie)
|
||||
}
|
||||
if (definition.syncContributor) {
|
||||
syncContributors.push(definition.syncContributor)
|
||||
}
|
||||
}
|
||||
|
||||
export function getRegisteredPlugins() {
|
||||
return [...plugins.values()]
|
||||
}
|
||||
|
||||
export function getPluginRoutes() {
|
||||
return getRegisteredPlugins().flatMap((p) => p.routes || [])
|
||||
}
|
||||
|
||||
export function getPluginNavItems() {
|
||||
return navItems
|
||||
}
|
||||
|
||||
export function getContactFormExtensions() {
|
||||
return contactFormExtensions
|
||||
}
|
||||
|
||||
export function getGraphToolbarActions() {
|
||||
return graphToolbarActions
|
||||
}
|
||||
|
||||
export function applyDexiePluginUpgrades(db) {
|
||||
dexieUpgraders.forEach((fn) => fn(db))
|
||||
}
|
||||
|
||||
export function getSyncContributors() {
|
||||
return syncContributors
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply graph extension hooks from all plugins.
|
||||
* @param {'node'|'edge'|'filters'} kind
|
||||
* @param {*} payload
|
||||
*/
|
||||
export function applyGraphExtensions(kind, payload) {
|
||||
let result = payload
|
||||
for (const plugin of plugins.values()) {
|
||||
const hooks = plugin.graphExtensions
|
||||
if (!hooks) continue
|
||||
if (kind === 'node' && hooks.extendNode) {
|
||||
result = hooks.extendNode(result) || result
|
||||
}
|
||||
if (kind === 'edge' && hooks.extendEdge) {
|
||||
result = hooks.extendEdge(result) || result
|
||||
}
|
||||
if (kind === 'filters' && hooks.extendFilters) {
|
||||
result = hooks.extendFilters(result) || result
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { registerPlugin, getPluginNavItems, getRegisteredPlugins } from './pluginRegistry.js'
|
||||
|
||||
describe('pluginRegistry', () => {
|
||||
it('registers nav items from plugin definition', () => {
|
||||
registerPlugin({
|
||||
id: 'test-plugin',
|
||||
navItems: [{ to: '/test', label: 'Test' }],
|
||||
})
|
||||
const items = getPluginNavItems()
|
||||
expect(items.some((i) => i.to === '/test')).toBe(true)
|
||||
expect(getRegisteredPlugins().some((p) => p.id === 'test-plugin')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
|
||||
/**
|
||||
* Create vis-network instance with shared defaults.
|
||||
*/
|
||||
export function createVisNetwork(container, nodes, edges, options = {}) {
|
||||
const nodesDS = new DataSet(nodes)
|
||||
const edgesDS = new DataSet(edges)
|
||||
const network = new Network(
|
||||
container,
|
||||
{ nodes: nodesDS, edges: edgesDS },
|
||||
{
|
||||
layout: { improvedLayout: false },
|
||||
interaction: {
|
||||
tooltipDelay: 200,
|
||||
hover: true,
|
||||
hideEdgesOnDrag: true,
|
||||
selectConnectedEdges: false,
|
||||
zoomView: true,
|
||||
dragView: true,
|
||||
dragNodes: true,
|
||||
},
|
||||
edges: { chosen: { label: false } },
|
||||
nodes: { borderWidth: 1.5 },
|
||||
...options,
|
||||
}
|
||||
)
|
||||
return { network, nodesDS, edgesDS }
|
||||
}
|
||||
|
||||
export function destroyVisNetwork(network, resizeObserver = null) {
|
||||
resizeObserver?.disconnect()
|
||||
network?.destroy()
|
||||
}
|
||||
|
||||
export function defaultPhysicsOptions(enabled, { fitOnStabilize = true } = {}) {
|
||||
return {
|
||||
enabled,
|
||||
solver: 'forceAtlas2Based',
|
||||
forceAtlas2Based: {
|
||||
gravitationalConstant: -120,
|
||||
centralGravity: 0.002,
|
||||
springLength: 200,
|
||||
springConstant: 0.035,
|
||||
damping: 0.5,
|
||||
avoidOverlap: 1,
|
||||
},
|
||||
stabilization: enabled
|
||||
? { iterations: 200, fit: fitOnStabilize, updateInterval: 25 }
|
||||
: undefined,
|
||||
maxVelocity: 20,
|
||||
}
|
||||
}
|
||||
@@ -32,16 +32,20 @@ export function getNetworkMapMembershipRepository() {
|
||||
|
||||
export async function getRelationTypes() {
|
||||
if (mode() === 'remote') {
|
||||
const { data } = await api.get('/relation-types/')
|
||||
return data
|
||||
const { data } = await api.get('/meta/choices/')
|
||||
return data.relation_types
|
||||
}
|
||||
return RELATION_TYPES
|
||||
}
|
||||
|
||||
export async function getNetworkMapChoices() {
|
||||
if (mode() === 'remote') {
|
||||
const { data } = await api.get('/network-map-choices/')
|
||||
return data
|
||||
const { data } = await api.get('/meta/choices/')
|
||||
return {
|
||||
life_spheres: data.life_spheres,
|
||||
network_circles: data.network_circles,
|
||||
interaction_intensities: data.interaction_intensities,
|
||||
}
|
||||
}
|
||||
return {
|
||||
life_spheres: LIFE_SPHERES,
|
||||
@@ -49,3 +53,16 @@ export async function getNetworkMapChoices() {
|
||||
interaction_intensities: INTERACTION_INTENSITIES,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMetaChoices() {
|
||||
if (mode() === 'remote') {
|
||||
const { data } = await api.get('/meta/choices/')
|
||||
return data
|
||||
}
|
||||
return {
|
||||
relation_types: RELATION_TYPES,
|
||||
life_spheres: LIFE_SPHERES,
|
||||
network_circles: NETWORK_CIRCLES,
|
||||
interaction_intensities: INTERACTION_INTENSITIES,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { listPendingChanges, ackChanges } from './changeLogRepository'
|
||||
import { getDataMode } from '../config/dataMode'
|
||||
import { getSyncContributors } from '../../core/pluginRegistry'
|
||||
import { noopSyncAdapter } from './noopSyncAdapter'
|
||||
|
||||
/**
|
||||
* Hybrid sync orchestrator: pushes core changelog + plugin contributors.
|
||||
*/
|
||||
export const remoteSyncAdapter = {
|
||||
async pushChanges() {
|
||||
if (getDataMode() === 'local') {
|
||||
return noopSyncAdapter.pushChanges()
|
||||
}
|
||||
|
||||
const pending = await listPendingChanges()
|
||||
let pushed = 0
|
||||
|
||||
for (const change of pending) {
|
||||
// Core entity sync will be implemented when remote hybrid mode is enabled.
|
||||
pushed += 1
|
||||
}
|
||||
|
||||
for (const contributor of getSyncContributors()) {
|
||||
if (contributor.pushChanges) {
|
||||
await contributor.pushChanges()
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.length) {
|
||||
await ackChanges(pending.map((c) => c.id))
|
||||
}
|
||||
|
||||
return { pushed, pending: Math.max(0, pending.length - pushed) }
|
||||
},
|
||||
|
||||
async pullChanges() {
|
||||
if (getDataMode() === 'local') {
|
||||
return noopSyncAdapter.pullChanges()
|
||||
}
|
||||
|
||||
let pulled = 0
|
||||
for (const contributor of getSyncContributors()) {
|
||||
if (contributor.pullChanges) {
|
||||
const result = await contributor.pullChanges()
|
||||
pulled += result?.pulled || 0
|
||||
}
|
||||
}
|
||||
return { pulled }
|
||||
},
|
||||
|
||||
async ack() {
|
||||
return { ok: true }
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { getSyncContributors } from '../../core/pluginRegistry'
|
||||
import { noopSyncAdapter } from './noopSyncAdapter'
|
||||
import { remoteSyncAdapter } from './remoteSyncAdapter'
|
||||
|
||||
/**
|
||||
* @returns {{pushChanges: Function, pullChanges: Function, ack: Function}}
|
||||
*/
|
||||
export function getSyncAdapter() {
|
||||
return noopSyncAdapter
|
||||
const contributors = getSyncContributors()
|
||||
if (!contributors.length) {
|
||||
return noopSyncAdapter
|
||||
}
|
||||
return remoteSyncAdapter
|
||||
}
|
||||
|
||||
+16
-5
@@ -1,10 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { bootstrapPlugins } from './core/bootstrapPlugins'
|
||||
import { applyDexiePluginUpgrades } from './core/pluginRegistry'
|
||||
import { localDb } from './infrastructure/db/localDb'
|
||||
import { initRouter } from './router'
|
||||
import './assets/style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
async function bootstrap() {
|
||||
await bootstrapPlugins()
|
||||
applyDexiePluginUpgrades(localDb)
|
||||
const router = await initRouter()
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Template for new frontend plugins.
|
||||
* Copy to frontend/src/plugins/<your-id>/ and register in bootstrapPlugins.js loaders.
|
||||
*/
|
||||
import { registerPlugin } from '../../core/pluginRegistry'
|
||||
|
||||
export function registerMyPlugin() {
|
||||
registerPlugin({
|
||||
id: 'my-plugin',
|
||||
version: '1.0.0',
|
||||
minCoreVersion: '1.0.0',
|
||||
permissions: [],
|
||||
routes: [],
|
||||
navItems: [],
|
||||
})
|
||||
}
|
||||
|
||||
// registerMyPlugin()
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="form-group">
|
||||
<label>Теги</label>
|
||||
<input
|
||||
v-model="tagsText"
|
||||
class="form-control"
|
||||
placeholder="коллеги, спорт, семья (через запятую)"
|
||||
@blur="emitTags"
|
||||
/>
|
||||
<p v-if="tags.length" class="text-muted" style="font-size:12px;margin-top:6px;">
|
||||
<span v-for="tag in tags" :key="tag" class="tag-chip">{{ tag }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { tagRepository } from './tagRepository.local'
|
||||
|
||||
const props = defineProps({
|
||||
contactId: { type: [String, Number], default: null },
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const tags = ref([...props.modelValue])
|
||||
const tagsText = ref(tags.value.join(', '))
|
||||
|
||||
async function loadTags() {
|
||||
if (!props.contactId) return
|
||||
const rows = await tagRepository.listByContact(props.contactId)
|
||||
tags.value = rows.map((r) => r.label)
|
||||
tagsText.value = tags.value.join(', ')
|
||||
emit('update:modelValue', tags.value)
|
||||
}
|
||||
|
||||
function emitTags() {
|
||||
tags.value = tagsText.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
emit('update:modelValue', [...new Set(tags.value)])
|
||||
}
|
||||
|
||||
watch(() => props.contactId, loadTags, { immediate: true })
|
||||
onMounted(loadTags)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tag-chip {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Теги контактов</h2>
|
||||
</div>
|
||||
<div class="page-content content-narrow">
|
||||
<div class="card">
|
||||
<p class="text-muted section-subtitle">
|
||||
Группировка контактов метками. Плагин <strong>tags</strong> (reference implementation).
|
||||
</p>
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="!grouped.length" class="empty-state">
|
||||
<p>Тегов пока нет. Добавьте теги в карточке контакта.</p>
|
||||
</div>
|
||||
<div v-else class="tag-groups">
|
||||
<div v-for="group in grouped" :key="group.label" class="tag-group">
|
||||
<h3>{{ group.label }} <span class="text-muted">({{ group.contacts.length }})</span></h3>
|
||||
<ul>
|
||||
<li v-for="name in group.contacts" :key="name">{{ name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useContactsStore } from '../../stores/contacts'
|
||||
import { tagRepository } from './tagRepository.local'
|
||||
|
||||
const store = useContactsStore()
|
||||
const loading = ref(true)
|
||||
const allTags = ref([])
|
||||
|
||||
const grouped = computed(() => {
|
||||
const byLabel = new Map()
|
||||
const contactById = new Map(store.contacts.map((c) => [String(c.id), c.name]))
|
||||
for (const tag of allTags.value) {
|
||||
const name = contactById.get(String(tag.contactId)) || tag.contactId
|
||||
if (!byLabel.has(tag.label)) byLabel.set(tag.label, [])
|
||||
byLabel.get(tag.label).push(name)
|
||||
}
|
||||
return [...byLabel.entries()]
|
||||
.map(([label, contacts]) => ({ label, contacts: contacts.sort((a, b) => String(a).localeCompare(String(b), 'ru')) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchContacts()
|
||||
allTags.value = await tagRepository.listAll()
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-narrow { max-width: 640px; }
|
||||
.section-subtitle { margin-bottom: 16px; }
|
||||
.tag-group { margin-bottom: 16px; }
|
||||
.tag-group h3 { font-size: 14px; margin-bottom: 6px; }
|
||||
.tag-group ul { margin: 0; padding-left: 18px; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { registerPlugin } from '../../core/pluginRegistry'
|
||||
import TagsView from './TagsView.vue'
|
||||
import ContactTagsFieldset from './ContactTagsFieldset.vue'
|
||||
import { tagRepository } from './tagRepository.local'
|
||||
|
||||
registerPlugin({
|
||||
id: 'tags',
|
||||
version: '1.0.0',
|
||||
minCoreVersion: '1.0.0',
|
||||
permissions: ['read:contacts', 'write:contacts'],
|
||||
routes: [
|
||||
{
|
||||
path: '/tags',
|
||||
name: 'Tags',
|
||||
component: TagsView,
|
||||
meta: { title: 'Теги' },
|
||||
},
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: '/tags',
|
||||
label: 'Теги',
|
||||
icon: 'tags',
|
||||
},
|
||||
],
|
||||
graphToolbarActions: [
|
||||
{
|
||||
id: 'open-tags',
|
||||
label: 'Теги',
|
||||
onClick: ({ router }) => router.push('/tags'),
|
||||
},
|
||||
],
|
||||
contactFormExtensions: [ContactTagsFieldset],
|
||||
upgradeDexie(db) {
|
||||
db.version(3).stores({
|
||||
contacts: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||
relations: 'id, source, target, updatedAt, deletedAt, workspaceId',
|
||||
networkMaps: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||
networkMapMemberships: 'id, mapId, contactId, updatedAt, deletedAt, [mapId+contactId]',
|
||||
meta: 'key',
|
||||
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
||||
contactTags: 'id, contactId, label, updatedAt, deletedAt, workspaceId',
|
||||
})
|
||||
},
|
||||
syncContributor: {
|
||||
entityType: 'plugin:tags',
|
||||
async pushChanges() {
|
||||
return { pushed: 0 }
|
||||
},
|
||||
async pullChanges() {
|
||||
return { pulled: 0 }
|
||||
},
|
||||
},
|
||||
graphExtensions: {
|
||||
extendNode(node) {
|
||||
return node
|
||||
},
|
||||
},
|
||||
tagRepository,
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { localDb } from '../../infrastructure/db/localDb'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
export const tagRepository = {
|
||||
async listByContact(contactId) {
|
||||
const sid = String(contactId)
|
||||
const all = await localDb.contactTags?.toArray() || []
|
||||
return all.filter((t) => !t.deletedAt && String(t.contactId) === sid)
|
||||
},
|
||||
|
||||
async listAll() {
|
||||
const all = await localDb.contactTags?.toArray() || []
|
||||
return all.filter((t) => !t.deletedAt)
|
||||
},
|
||||
|
||||
async setContactTags(contactId, labels = []) {
|
||||
if (!localDb.contactTags) return []
|
||||
const sid = String(contactId)
|
||||
const ts = nowIso()
|
||||
const normalized = [...new Set(labels.map((l) => String(l).trim()).filter(Boolean))]
|
||||
const existing = await this.listByContact(sid)
|
||||
const existingLabels = new Set(existing.map((t) => t.label))
|
||||
|
||||
for (const tag of existing) {
|
||||
if (!normalized.includes(tag.label)) {
|
||||
await localDb.contactTags.update(tag.id, { deletedAt: ts, updatedAt: ts })
|
||||
}
|
||||
}
|
||||
|
||||
for (const label of normalized) {
|
||||
if (existingLabels.has(label)) continue
|
||||
await localDb.contactTags.put({
|
||||
id: generateId(),
|
||||
contactId: sid,
|
||||
label,
|
||||
workspaceId: 'personal',
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
deletedAt: null,
|
||||
})
|
||||
}
|
||||
return this.listByContact(sid)
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getPluginRoutes } from '../core/pluginRegistry'
|
||||
|
||||
const routes = [
|
||||
const coreRoutes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/graph',
|
||||
@@ -37,9 +38,25 @@ const routes = [
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
let router = null
|
||||
|
||||
export default router
|
||||
export function buildRoutes() {
|
||||
return [...coreRoutes, ...getPluginRoutes()]
|
||||
}
|
||||
|
||||
export async function initRouter() {
|
||||
router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: buildRoutes(),
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
export function getRouter() {
|
||||
if (!router) {
|
||||
throw new Error('Router not initialized. Call initRouter() after bootstrapPlugins().')
|
||||
}
|
||||
return router
|
||||
}
|
||||
|
||||
export default getRouter
|
||||
|
||||
@@ -246,9 +246,11 @@ async function loadContact() {
|
||||
await mapsStore.fetchContactMemberships(contactId.value)
|
||||
}
|
||||
|
||||
async function onUpdate(data, mapIds) {
|
||||
async function onUpdate(data, mapIds, pluginPayload) {
|
||||
await store.updateContact(contactId.value, data)
|
||||
await mapsStore.setContactMapMemberships(contactId.value, mapIds)
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(contactId.value, pluginPayload)
|
||||
contact.value = { ...contact.value, ...data }
|
||||
editing.value = false
|
||||
await mapsStore.fetchContactMemberships(contactId.value)
|
||||
|
||||
@@ -281,11 +281,13 @@ function onRelationCreated() {
|
||||
|
||||
function goTo(id) { router.push(`/contacts/${id}`) }
|
||||
|
||||
async function onCreate(data, mapIds) {
|
||||
async function onCreate(data, mapIds, pluginPayload) {
|
||||
const created = await store.createContact(data)
|
||||
if (mapIds?.length) {
|
||||
await mapsStore.setContactMapMemberships(created.id, mapIds)
|
||||
}
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(created.id, pluginPayload)
|
||||
showCreate.value = false
|
||||
}
|
||||
|
||||
@@ -293,9 +295,11 @@ function openEdit(c) {
|
||||
editTarget.value = { ...c }
|
||||
}
|
||||
|
||||
async function onUpdate(data, mapIds) {
|
||||
async function onUpdate(data, mapIds, pluginPayload) {
|
||||
await store.updateContact(editTarget.value.id, data)
|
||||
await mapsStore.setContactMapMemberships(editTarget.value.id, mapIds)
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(editTarget.value.id, pluginPayload)
|
||||
editTarget.value = null
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,17 @@
|
||||
:active-values="activeFilters"
|
||||
@toggle="toggleFilter"
|
||||
/>
|
||||
<div v-if="graphToolbarActions.length" class="graph-plugin-actions">
|
||||
<button
|
||||
v-for="action in graphToolbarActions"
|
||||
:key="action.id"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="runGraphToolbarAction(action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="graph-area" ref="graphArea">
|
||||
@@ -150,7 +161,7 @@
|
||||
defineOptions({ name: 'Graph' })
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
@@ -168,6 +179,7 @@ import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
||||
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
import { getGraphToolbarActions } from '../core/pluginRegistry'
|
||||
|
||||
let themeObserver = null
|
||||
let detachContextHandler = null
|
||||
@@ -191,6 +203,8 @@ function openNodeInfo(node) {
|
||||
}
|
||||
|
||||
const store = useContactsStore()
|
||||
const router = useRouter()
|
||||
const graphToolbarActions = getGraphToolbarActions()
|
||||
const graphArea = ref(null)
|
||||
const graphContainer = ref(null)
|
||||
const loading = ref(true)
|
||||
@@ -774,6 +788,10 @@ function toggleChrome() {
|
||||
nextTick(() => network.value?.redraw())
|
||||
}
|
||||
|
||||
function runGraphToolbarAction(action) {
|
||||
action.onClick?.({ router })
|
||||
}
|
||||
|
||||
watch(() => store.dataRevision, async (revision) => {
|
||||
if (!network.value || revision === syncedRevision) return
|
||||
await applyGraphDataFromStore()
|
||||
@@ -832,6 +850,11 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28px 10px;
|
||||
}
|
||||
.graph-plugin-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.graph-chrome-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user