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:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user