Создание модуля
Генерация модуля
Используйте 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:
{
"name": "vendor/my-module",
"autoload": {
"psr-4": {
"Vendor\\MyModule\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Vendor\\MyModule\\MyModuleServiceProvider"
]
}
}
}
Подключите в основной composer.json:
{
"repositories": [
{
"type": "path",
"url": "./packages/my-module",
"options": { "symlink": true }
}
],
"require": {
"vendor/my-module": "*"
}
}
ServiceProvider
Точка входа модуля — ServiceProvider. Он регистрирует модуль в навигации, загружает роуты и конфигурацию.
<?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
tabler-puzzle). Рендерится через svg().0. Меньшее значение — выше в списке. Позволяет контролировать позицию модуля независимо от порядка вызова register() в ServiceProvider.Регистрация ресурса
Если модуль содержит ресурс, его нужно зарегистрировать явно, так как автоматическое сканирование работает только для app/Frame/Resources/:
protected function registerResource(): void
{
Frame::register(MyResource::class);
}
Скрытый ресурс
Чтобы ресурс не появлялся в навигации и глобальном поиске, но был доступен через API:
class MyResource extends Resource
{
public static bool $globalSearchAllowed = false;
public static bool $showInGlobalNavigation = false;
public static ?bool $searchIndexed = true; // индексируется для поиска в таблице
}
Frontend — страница модуля
Регистрация роута
Используйте extendRoutes из @frame/vue до инициализации FramePlugin:
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 },
},
])
}
Компонент страницы
<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 через систему слотов — как с фронтенда, так и с бэкенда.
Подробная документация: Слоты.
Краткий пример:
Как это работает
- Модуль вызывает
AppSlotRegistry.register()при инициализации - В нужном месте интерфейса Frame стоит
<AppSlotRenderer location="..." :context="..." /> - Рендерер находит все компоненты, зарегистрированные для этого location, и рендерит их, передавая context как пропсы
Регистрация слота
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
location:name.markRaw().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 | После основного контента | Да |
GET /slots.Context — пропсы компонента
Каждый location передаёт свой набор данных через context. Компонент получает их как обычные пропсы Vue.
resource-page-actions
<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 ресурса
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,
})
<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 — например, показывать баннер на странице входа или инструкции в сайдбаре.
Регистрация на бэкенде
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-компонент ей соответствует:
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)
Как это работает
- Бэкенд регистрирует слоты через
Frame::registerSlots() - Для авторизованных страниц — данные приходят через
GET /meta(вместе с модулями и дашбордами) - Для публичных страниц (login) — через
GET /slots(без авторизации) 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
SlotComponentRegistry на фронтенде.Модальные окна
Для открытия модальных окон из модуля используйте 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.