Поддержка
Модули

Создание модуля

Пошаговое руководство по созданию собственного модуля для Frame

Генерация модуля

Используйте Artisan-команду для создания заготовки модуля:

php artisan frame:module vendor/my-module

Команда создаст структуру пакета в директории packages/my-module/:

packages/my-module/
├── composer.json              # Пакет с auto-discovery
├── config/my-module.php       # Публикуемая конфигурация
├── manifest.json              # Метаданные модуля
├── src/
│   ├── ModuleServiceProvider.php   # Gate, ModuleManager, routes
│   └── routes/
│       └── api.php            # API-роуты модуля
├── resources/
│   └── js/
│       ├── module.js          # Frontend: регистрация роута
│       └── Page.vue           # Заготовка страницы
└── tests/

После генерации команда автоматически добавит path-репозиторий в корневой composer.json и предложит запустить composer update.

Имя модуля задаётся в формате vendor/name — например acme/analytics. Vendor используется как namespace (Acme\Analytics), name — как slug для роутов и конфига.

Структура пакета

composer.json

Генерируется автоматически. Содержит auto-discovery для ServiceProvider:

packages/my-module/composer.json
{
  "name": "vendor/my-module",
  "autoload": {
    "psr-4": {
      "Vendor\\MyModule\\": "src/"
    }
  },
  "extra": {
    "laravel": {
      "providers": [
        "Vendor\\MyModule\\MyModuleServiceProvider"
      ]
    }
  }
}

Подключите в основной composer.json:

composer.json
{
  "repositories": [
    {
      "type": "path",
      "url": "./packages/my-module",
      "options": { "symlink": true }
    }
  ],
  "require": {
    "vendor/my-module": "*"
  }
}

ServiceProvider

Точка входа модуля — ServiceProvider. Он регистрирует модуль в навигации, загружает роуты и конфигурацию.

src/MyModuleServiceProvider.php
<?php

namespace Vendor\MyModule;

use Frame\Core\Frame;
use Frame\Core\Modules\Module;
use Frame\Core\Modules\ModuleManager;
use Frame\Laravel\Http\Requests\FrameRequest;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;

class MyModuleServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__ . '/../config/my-module.php', 'my-module');
    }

    public function boot(ModuleManager $moduleManager): void
    {
        $this->registerGate();
        $this->registerModule($moduleManager);
        $this->registerRoutes();
    }

    protected function registerGate(): void
    {
        Gate::define('frame-my-module', fn ($user) => (bool) $user);
    }

    protected function registerModule(ModuleManager $moduleManager): void
    {
        $module = Module::make([
            'title' => __('My Module'),
            'caption' => __('Module description'),
            'version' => '1.0.0',
            'link' => '/my-module',
            'icon' => 'tabler-puzzle',
            'order' => 10,
        ]);

        $module->authorize(
            fn (FrameRequest $request) => $request->user()?->can('frame-my-module') ?? false,
        );

        $moduleManager->register($module);
    }

    protected function registerRoutes(): void
    {
        Route::group(Frame::apiRoutesConfiguration(), function (): void {
            $this->loadRoutesFrom(__DIR__ . '/routes/api.php');
        });
    }
}

Свойства Module

title
string
Название модуля. Отображается в навигации и на странице модулей.
caption
string|null
Описание модуля. Показывается как подпись в навигации.
version
string|null
Версия модуля.
link
string|null
URL-путь к странице модуля. Обязателен для отображения в навигации.
icon
string|null
Имя иконки (например tabler-puzzle). Рендерится через svg().
order
int
Порядок отображения в навигации. По умолчанию 0. Меньшее значение — выше в списке. Позволяет контролировать позицию модуля независимо от порядка вызова register() в ServiceProvider.

Регистрация ресурса

Если модуль содержит ресурс, его нужно зарегистрировать явно, так как автоматическое сканирование работает только для app/Frame/Resources/:

protected function registerResource(): void
{
    Frame::register(MyResource::class);
}

Скрытый ресурс

Чтобы ресурс не появлялся в навигации и глобальном поиске, но был доступен через API:

src/Resources/MyResource.php
class MyResource extends Resource
{
    public static bool $globalSearchAllowed = false;
    public static bool $showInGlobalNavigation = false;
    public static ?bool $searchIndexed = true; // индексируется для поиска в таблице
}

Frontend — страница модуля

Регистрация роута

Используйте extendRoutes из @frame/vue до инициализации FramePlugin:

resources/js/my-module/index.ts
import { AppSlotRegistry } from '@frame/core'
import { extendRoutes, DashboardLayout } from '@frame/vue'

export function registerMyModule() {
  extendRoutes([
    {
      path: '/my-module',
      name: 'my-module',
      component: () => import('./MyModulePage.vue'),
      meta: { layout: DashboardLayout },
    },
  ])
}

Компонент страницы

resources/js/my-module/MyModulePage.vue
<template>
  <Page
    :title="translate('My Module')"
    :breadcrumbs="[
      { label: translate('Home'), to: '/' },
      { label: translate('My Module') },
    ]"
  >
    <!-- содержимое -->
  </Page>
</template>

<script lang="ts" setup>
import { translate } from '@frame/core'
import { Page } from '@frame/vue'
</script>

Использование таблицы ресурса

Для отображения данных ресурса в кастомной странице используйте ResourceTable:

<template>
  <Table
    resource="my-resources"
    :disable-router-link="true"
    @row-click="onRowClick"
  />
</template>

<script setup>
import { ResourceTable as Table } from '@frame/vue'
</script>

Проп disableRouterLink отключает навигацию по клику на строку, а событие row-click позволяет обработать клик (например, открыть модальное окно).

Слоты

Модули могут встраивать компоненты в интерфейс Frame через систему слотов — как с фронтенда, так и с бэкенда.

Подробная документация: Слоты.

Краткий пример:

Как это работает

  1. Модуль вызывает AppSlotRegistry.register() при инициализации
  2. В нужном месте интерфейса Frame стоит <AppSlotRenderer location="..." :context="..." />
  3. Рендерер находит все компоненты, зарегистрированные для этого location, и рендерит их, передавая context как пропсы

Регистрация слота

resources/js/my-module/index.ts
import { AppSlotRegistry } from '@frame/core'
import { markRaw } from 'vue'
import MyButton from './MyButton.vue'

AppSlotRegistry.register({
  name: 'my-module-button',        // уникальное имя
  location: 'resource-page-actions', // куда вставить
  component: markRaw(MyButton),      // Vue-компонент (обязательно markRaw)
  order: 10,                         // порядок рендера (меньше = раньше)
})
Всегда оборачивайте компонент в markRaw() — это предотвращает превращение компонента в реактивный объект и улучшает производительность.

AppSlotDefinition

name
string
Уникальное имя слота. Используется как ключ в реестре — location:name.
location
string
Место в интерфейсе, где компонент будет отрендерен. См. таблицу доступных locations ниже.
component
Component
Vue-компонент для рендера. Оборачивайте в markRaw().
props
Record<string, any>
Статические пропсы, которые всегда передаются компоненту (помимо context).
order
number
Порядок рендера. По умолчанию 0. Компоненты с меньшим order рендерятся первыми.

Доступные locations

Авторизация

LocationМестоПубличный
login-before-formПеред формой входаДа
login-after-formПосле формы входаДа
login-sidebarПравая панель страницы авторизации (только десктоп)Да

Ресурсы

LocationМестоContext
resource-page-actionsСправа от breadcrumbs на странице записиresource, resourceId, record
resource-before-formПеред формой записи
resource-after-formПосле формы записи
resource-before-relationsПеред связями
resource-after-relationsПосле связей

Дашборд

LocationМесто
dashboard-before-cardsПеред карточками дашборда
dashboard-after-cardsПосле карточек дашборда

Глобальные

LocationМестоПубличный
global-before-contentПеред основным контентомДа
global-after-contentПосле основного контентаДа
Публичные locations доступны без авторизации — например, на странице входа. Они обслуживаются через отдельный endpoint GET /slots.

Context — пропсы компонента

Каждый location передаёт свой набор данных через context. Компонент получает их как обычные пропсы Vue.

resource-page-actions

MyButton.vue
<script lang="ts" setup>
const props = defineProps<{
  resource: string   // имя ресурса, например 'users' или 'posts'
  resourceId: number // ID записи
  record: {          // данные записи из API
    title: string
    label: string
    group?: string
  }
}>()
</script>

login-* и global-*

Не передают context — компонент рендерится без пропсов (только те, что заданы на бэкенде через props).

Методы AppSlotRegistry

import { AppSlotRegistry } from '@frame/core'

// Регистрация одного или нескольких слотов
AppSlotRegistry.register(definition)
AppSlotRegistry.register([def1, def2])

// Получить все слоты для location
AppSlotRegistry.getByLocation('resource-page-actions')

// Проверить, есть ли слоты для location
AppSlotRegistry.has('resource-page-actions')

// Удалить конкретный слот
AppSlotRegistry.unregister('resource-page-actions', 'my-module-button')

// Очистить все слоты (или только для конкретного location)
AppSlotRegistry.clear()
AppSlotRegistry.clear('resource-page-actions')

Пример: кнопка в header ресурса

index.ts
import { AppSlotRegistry } from '@frame/core'
import { markRaw } from 'vue'
import HistoryButton from './HistoryButton.vue'

AppSlotRegistry.register({
  name: 'action-events-history',
  location: 'resource-page-actions',
  component: markRaw(HistoryButton),
  order: 10,
})
HistoryButton.vue
<template>
  <Button
    :disabled="!hasHistory"
    :icon="IconHistory"
    color="neutral"
    size="sm"
    variant="ghost"
    @click="openHistoryModal(resource, resourceId)"
  />
</template>

<script lang="ts" setup>
import { Button } from '@frame/vue'
import { IconHistory } from '@tabler/icons-vue'

const props = defineProps<{
  resource: string
  resourceId: number
  record: any
}>()

// ...логика проверки hasHistory через API
</script>

Серверные слоты

Помимо фронтенд-регистрации через AppSlotRegistry, слоты можно регистрировать с бэкенда. Это позволяет управлять содержимым интерфейса из PHP — например, показывать баннер на странице входа или инструкции в сайдбаре.

Регистрация на бэкенде

app/Providers/FrameServiceProvider.php
use Frame\Core\Enums\SlotLocation;
use Frame\Core\Frame;
use Frame\Core\Slot;

Frame::registerSlots([
    SlotLocation::LOGIN_BEFORE_FORM->value => Slot::make('Banner', [
        'text' => 'Плановые работы сегодня с 20:00 до 22:00',
        'variant' => 'warning',
    ]),
    SlotLocation::LOGIN_SIDEBAR->value => Slot::make('LoginSidebar'),
]);

Slot::make(componentName, props) — первый аргумент это имя компонента (строка), второй — пропсы, которые будут переданы компоненту на фронтенде.

Регистрация компонента на фронтенде

Бэкенд передаёт имя компонента как строку. Фронтенд должен знать, какой Vue-компонент ей соответствует:

resources/js/frame.js
import { SlotComponentRegistry } from '@frame/core'
import Banner from './components/Banner.vue'
import LoginSidebar from './components/LoginSidebar.vue'

SlotComponentRegistry.register('Banner', Banner)
SlotComponentRegistry.register('LoginSidebar', LoginSidebar)

Как это работает

  1. Бэкенд регистрирует слоты через Frame::registerSlots()
  2. Для авторизованных страниц — данные приходят через GET /meta (вместе с модулями и дашбордами)
  3. Для публичных страниц (login) — через GET /slots (без авторизации)
  4. AppSlotRenderer резолвит компонент по имени из SlotComponentRegistry и рендерит с пропсами

SlotLocation

Доступные locations определены в enum Frame\Core\Enums\SlotLocation. Каждый location имеет флаг isPublic() — публичные locations доступны через /slots без авторизации.

SlotLocation::LOGIN_BEFORE_FORM->value   // 'login-before-form' (публичный)
SlotLocation::LOGIN_SIDEBAR->value       // 'login-sidebar' (публичный)
SlotLocation::RESOURCE_PAGE_ACTIONS->value // 'resource-page-actions' (требует авторизации)

Slot

component
string
Имя компонента, зарегистрированного через SlotComponentRegistry на фронтенде.
props
array
Массив пропсов, которые будут переданы компоненту. Поддерживает любые JSON-сериализуемые значения, включая HTML-строки.

Модальные окна

Для открытия модальных окон из модуля используйте openModal:

import { openModal } from '@frame/vue'
import { markRaw } from 'vue'
import MyModal from './MyModal.vue'

const { close } = openModal({
  component: markRaw(MyModal),
  attrs: {
    myProp: value,
    onCancel: () => close(),
  },
})

Для оформления модального окна используйте DefaultModalLayout:

<template>
  <DefaultModalLayout title="Title" @cancel="$emit('cancel')">
    <!-- содержимое -->
  </DefaultModalLayout>
</template>

<script setup>
import { DefaultModalLayout } from '@frame/vue'
</script>

Переводы

На бэкенде используйте __() — Laravel найдёт переводы в lang/ru.json проекта:

'title' => __('Action events'),

На фронтенде используйте translate() из @frame/core:

import { translate } from '@frame/core'
translate('Action events')

Переводы фронтенда определяются в JSON-файле, передаваемом в FramePlugin через i18n.