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\ContentBuilder\UploadContentBuilderFilesAction;
|
||||||
use App\Containers\Dashboard\Actions\Pages\CreatePageAction;
|
use App\Containers\Dashboard\Actions\Pages\CreatePageAction;
|
||||||
use App\Containers\Dashboard\Actions\Pages\DeletePageAction;
|
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\ListPagesAction;
|
||||||
use App\Containers\Dashboard\Actions\Pages\UpdatePageAction;
|
use App\Containers\Dashboard\Actions\Pages\UpdatePageAction;
|
||||||
use App\Containers\Dashboard\UI\WEB\Requests\StorePageRequest;
|
use App\Containers\Dashboard\UI\WEB\Requests\StorePageRequest;
|
||||||
@@ -14,6 +16,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
class PageController extends Controller
|
class PageController extends Controller
|
||||||
@@ -23,6 +26,8 @@ class PageController extends Controller
|
|||||||
private readonly CreatePageAction $createPageAction,
|
private readonly CreatePageAction $createPageAction,
|
||||||
private readonly UpdatePageAction $updatePageAction,
|
private readonly UpdatePageAction $updatePageAction,
|
||||||
private readonly DeletePageAction $deletePageAction,
|
private readonly DeletePageAction $deletePageAction,
|
||||||
|
private readonly ExportPageAction $exportPageAction,
|
||||||
|
private readonly ImportPageAction $importPageAction,
|
||||||
private readonly UploadContentBuilderFilesAction $uploadContentBuilderFilesAction,
|
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 и возвращает метаданные
|
* Загружает файлы для ContentBuilder и возвращает метаданные
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -375,8 +375,10 @@ Route::middleware(['access-check', 'dashboard.auth', 'dashboard.permission'])->g
|
|||||||
Route::get('/', [PageController::class, 'index'])->name('index');
|
Route::get('/', [PageController::class, 'index'])->name('index');
|
||||||
Route::get('/create', [PageController::class, 'create'])->name('create');
|
Route::get('/create', [PageController::class, 'create'])->name('create');
|
||||||
Route::post('/', [PageController::class, 'store'])->name('store');
|
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::post('/upload-files', [PageController::class, 'uploadFiles'])->name('upload-files');
|
||||||
Route::get('/{page}/edit', [PageController::class, 'edit'])->name('edit');
|
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::put('/{page}', [PageController::class, 'update'])->name('update');
|
||||||
Route::delete('/{page}', [PageController::class, 'destroy'])->name('destroy');
|
Route::delete('/{page}', [PageController::class, 'destroy'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,31 @@
|
|||||||
<template #header-title>Страницы</template>
|
<template #header-title>Страницы</template>
|
||||||
<template #header-subtitle>Управление страницами сайта</template>
|
<template #header-subtitle>Управление страницами сайта</template>
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<a
|
<div class="flex items-center gap-3">
|
||||||
:href="route('dashboard.pages.create')"
|
<button
|
||||||
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"
|
v-if="isLocal"
|
||||||
>
|
type="button"
|
||||||
<DashboardIcon name="plus" size="4" />
|
@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"
|
||||||
</a>
|
>
|
||||||
|
<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>
|
</template>
|
||||||
|
|
||||||
<!-- Flash Messages -->
|
<!-- Flash Messages -->
|
||||||
@@ -139,6 +157,13 @@
|
|||||||
>
|
>
|
||||||
<DashboardIcon name="pencil-square" size="4" />
|
<DashboardIcon name="pencil-square" size="4" />
|
||||||
</a>
|
</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
|
<button
|
||||||
@click.prevent="confirmDeletePage(page)"
|
@click.prevent="confirmDeletePage(page)"
|
||||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
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() {
|
mounted() {
|
||||||
this.SET_DOCUMENT_TITLE('Страницы');
|
this.SET_DOCUMENT_TITLE('Страницы');
|
||||||
},
|
},
|
||||||
@@ -269,6 +300,29 @@ export default {
|
|||||||
tab: this.tabQuery,
|
tab: this.tabQuery,
|
||||||
sub_section_id: this.subSectionQuery
|
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