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');
});
+61 -7
View File
@@ -6,13 +6,31 @@
<template #header-title>Страницы</template>
<template #header-subtitle>Управление страницами сайта</template>
<template #header-actions>
<a
:href="route('dashboard.pages.create')"
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
>
<DashboardIcon name="plus" size="4" />
Создать страницу
</a>
<div class="flex items-center gap-3">
<button
v-if="isLocal"
type="button"
@click="triggerImport"
class="inline-flex items-center gap-2 px-4 py-2 border border-layer-line bg-layer text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all duration-200"
>
<DashboardIcon name="arrow-up-tray" size="4" />
Импортировать страницу
</button>
<a
:href="route('dashboard.pages.create')"
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
>
<DashboardIcon name="plus" size="4" />
Создать страницу
</a>
</div>
<input
ref="importInput"
type="file"
accept=".json"
class="hidden"
@change="handleImportFile"
/>
</template>
<!-- Flash Messages -->
@@ -139,6 +157,13 @@
>
<DashboardIcon name="pencil-square" size="4" />
</a>
<button
@click.prevent="exportPage(page)"
class="p-2 text-muted-foreground-1 hover:text-emerald-600 hover:bg-emerald-500/10 rounded-lg transition-all"
title="Экспорт в JSON"
>
<DashboardIcon name="arrow-down-tray" size="4" />
</button>
<button
@click.prevent="confirmDeletePage(page)"
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
@@ -220,6 +245,12 @@ export default {
}
},
computed: {
isLocal() {
return this.$page?.props?.app?.env === 'local';
}
},
mounted() {
this.SET_DOCUMENT_TITLE('Страницы');
},
@@ -269,6 +300,29 @@ export default {
tab: this.tabQuery,
sub_section_id: this.subSectionQuery
});
},
triggerImport() {
this.$refs.importInput.click();
},
exportPage(page) {
window.location.href = route('dashboard.pages.export', page.id);
},
handleImportFile(event) {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('import_file', file);
this.$inertia.post(route('dashboard.pages.import'), formData, {
preserveScroll: true,
onFinish: () => {
event.target.value = '';
}
});
}
}
}