feat(dashboard): add page export/import functionality

- ExportPageAction: exports page with SEO and section path as JSON
- ImportPageAction: imports page from JSON, resolves section by slug path
- PageController: export/download and import endpoints
- Routes: GET /{page}/export, POST /import
- Index.vue: export button per row, import button in header (local only)
- Import restricted to APP_ENV=local (backend + frontend check)
This commit is contained in:
F4ilji
2026-08-03 10:21:14 +05:00
parent 3860c5d24c
commit d4ef7daeb1
5 changed files with 246 additions and 7 deletions
@@ -0,0 +1,42 @@
<?php
namespace App\Containers\Dashboard\Actions\Pages;
use App\Containers\AppStructure\Models\Page;
class ExportPageAction
{
public function run(Page $page): array
{
$page->load(['section.mainSection', 'seo']);
$sectionPath = null;
if ($page->section) {
$sectionPath = [
'main_section_slug' => $page->section->mainSection?->slug,
'sub_section_slug' => $page->section->slug,
];
}
return [
'export_version' => 1,
'exported_at' => now()->toIso8601String(),
'page' => [
'title' => $page->title,
'slug' => $page->slug,
'code' => $page->code,
'content' => $page->content,
'settings' => $page->settings,
'searchable' => $page->searchable,
'sort' => $page->sort,
'is_visible' => $page->is_visible,
],
'seo' => $page->seo ? [
'title' => $page->seo->title,
'description' => $page->seo->description,
] : null,
'section_path' => $sectionPath,
];
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Containers\Dashboard\Actions\Pages;
use App\Containers\AppStructure\Models\MainSection;
use App\Containers\AppStructure\Models\Page;
use App\Containers\AppStructure\Models\SubSection;
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
class ImportPageAction
{
public function __construct(
private readonly GenerateSearchDataTask $generateSearchDataTask,
) {}
/**
* Импортирует страницу из JSON-структуры
*
* @param array $data Раскодированный JSON-массив экспорта
* @return Page Созданная страница
* @throws \InvalidArgumentException
*/
public function run(array $data): Page
{
if (empty($data['page']['title'])) {
throw new \InvalidArgumentException('Отсутствует обязательное поле "page.title"');
}
if (empty($data['page']['slug'])) {
throw new \InvalidArgumentException('Отсутствует обязательное поле "page.slug"');
}
$pageData = $data['page'];
// Resolve sub_section_id via slug path
$pageData['sub_section_id'] = $this->resolveSubSectionId($data['section_path'] ?? null);
// Generate search_data from content
if (!empty($pageData['content'])) {
$pageData['search_data'] = $this->generateSearchDataTask->run($pageData['content']);
}
// Remove fields that should not be set during import
unset($pageData['icon']);
$page = Page::create($pageData);
// Attach SEO if present
if (!empty($data['seo'])) {
$page->seo()->create([
'title' => $data['seo']['title'] ?? null,
'description' => $data['seo']['description'] ?? null,
]);
}
return $page;
}
private function resolveSubSectionId(?array $sectionPath): ?int
{
if (empty($sectionPath)) {
return null;
}
$mainSectionSlug = $sectionPath['main_section_slug'] ?? null;
$subSectionSlug = $sectionPath['sub_section_slug'] ?? null;
if (!$mainSectionSlug || !$subSectionSlug) {
return null;
}
$mainSection = MainSection::where('slug', $mainSectionSlug)->first();
if (!$mainSection) {
return null;
}
$subSection = SubSection::where('main_section_id', $mainSection->id)
->where('slug', $subSectionSlug)
->first();
return $subSection?->id;
}
}
@@ -6,6 +6,8 @@ use App\Containers\AppStructure\Models\Page;
use App\Containers\Dashboard\Actions\ContentBuilder\UploadContentBuilderFilesAction;
use App\Containers\Dashboard\Actions\Pages\CreatePageAction;
use App\Containers\Dashboard\Actions\Pages\DeletePageAction;
use App\Containers\Dashboard\Actions\Pages\ExportPageAction;
use App\Containers\Dashboard\Actions\Pages\ImportPageAction;
use App\Containers\Dashboard\Actions\Pages\ListPagesAction;
use App\Containers\Dashboard\Actions\Pages\UpdatePageAction;
use App\Containers\Dashboard\UI\WEB\Requests\StorePageRequest;
@@ -14,6 +16,7 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Inertia\Inertia;
class PageController extends Controller
@@ -23,6 +26,8 @@ class PageController extends Controller
private readonly CreatePageAction $createPageAction,
private readonly UpdatePageAction $updatePageAction,
private readonly DeletePageAction $deletePageAction,
private readonly ExportPageAction $exportPageAction,
private readonly ImportPageAction $importPageAction,
private readonly UploadContentBuilderFilesAction $uploadContentBuilderFilesAction,
) {}
@@ -119,6 +124,58 @@ class PageController extends Controller
}
}
/**
* Экспортирует страницу в JSON-файл
*/
public function export(Page $page): Response
{
try {
$data = $this->exportPageAction->run($page);
$filename = $page->slug . '_' . now()->format('Y-m-d_H-i-s') . '.json';
return response(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), 200, [
'Content-Type' => 'application/json',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
} catch (\Exception $e) {
return back()->with('error', 'Ошибка при экспорте страницы: ' . $e->getMessage());
}
}
/**
* Импортирует страницу из JSON-файла
*/
public function import(Request $request): RedirectResponse
{
if (config('app.env') !== 'local') {
return back()->with('error', 'Импорт доступен только в окружении local');
}
try {
$request->validate([
'import_file' => 'required|file|mimes:json,txt|max:512',
]);
$file = $request->file('import_file');
$content = file_get_contents($file->getRealPath());
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return back()->with('error', 'Некорректный JSON: ' . json_last_error_msg());
}
$page = $this->importPageAction->run($data);
return redirect()->route('dashboard.pages.edit', $page)
->with('success', 'Страница "' . $page->title . '" успешно импортирована!');
} catch (\InvalidArgumentException $e) {
return back()->with('error', 'Ошибка валидации: ' . $e->getMessage());
} catch (\Exception $e) {
return back()->with('error', 'Ошибка при импорте страницы: ' . $e->getMessage());
}
}
/**
* Загружает файлы для ContentBuilder и возвращает метаданные
*/
@@ -375,8 +375,10 @@ Route::middleware(['access-check', 'dashboard.auth', 'dashboard.permission'])->g
Route::get('/', [PageController::class, 'index'])->name('index');
Route::get('/create', [PageController::class, 'create'])->name('create');
Route::post('/', [PageController::class, 'store'])->name('store');
Route::post('/import', [PageController::class, 'import'])->name('import');
Route::post('/upload-files', [PageController::class, 'uploadFiles'])->name('upload-files');
Route::get('/{page}/edit', [PageController::class, 'edit'])->name('edit');
Route::get('/{page}/export', [PageController::class, 'export'])->name('export');
Route::put('/{page}', [PageController::class, 'update'])->name('update');
Route::delete('/{page}', [PageController::class, 'destroy'])->name('destroy');
});