From 8364b220cc7acf848f1f00f42f15fcf7c8ca0afd Mon Sep 17 00:00:00 2001 From: F4ilji Date: Thu, 2 Jul 2026 01:03:13 +0500 Subject: [PATCH] feat: add Integration Credentials CRUD and Deploy UI Integration Credentials: - Add IntegrationCredential model with encrypted payload - Add full CRUD: ManageIntegrationCredentialsAction, 4 Tasks, Controller, Requests - Add migration for integration_credentials table - Add Vue pages: Index, Create, Edit - AI tasks now read API keys from DB instead of .env - Add sidebar menu item Deploy UI: - Add DeployTask with full deploy lifecycle management - Extend DeployController with index, log, history endpoints - Add Deploy/Index.vue with live polling and deploy log viewer - Add sidebar menu item and QuickAction for Sveden UI: - Add 'key' icon to SidebarNavItem - Change deploy button color to amber in Main.vue --- .../ManageIntegrationCredentialsAction.php | 47 +++ .../Models/IntegrationCredential.php | 15 + .../AI/CallAiServiceForFileSelectionTask.php | 16 +- .../Dashboard/Tasks/AI/CallAiServiceTask.php | 15 +- .../DeleteIntegrationCredentialTask.php | 20 ++ .../GetIntegrationCredentialTask.php | 21 ++ .../ListIntegrationCredentialsTask.php | 14 + .../SaveIntegrationCredentialTask.php | 22 ++ .../UI/WEB/Controllers/DeployController.php | 44 ++- .../IntegrationCredentialsController.php | 78 +++++ .../StoreIntegrationCredentialRequest.php | 23 ++ .../UpdateIntegrationCredentialRequest.php | 23 ++ ...0_create_integration_credentials_table.php | 24 ++ .../Dashboard/Components/SidebarNavItem.vue | 1 + .../Pages/Dashboard/Components/menuConfig.js | 14 + .../Components/shared/QuickActions.vue | 10 + resources/js/Pages/Dashboard/Deploy/Index.vue | 267 ++++++++++++++++++ .../IntegrationCredentials/Create.vue | 194 +++++++++++++ .../Dashboard/IntegrationCredentials/Edit.vue | 210 ++++++++++++++ .../IntegrationCredentials/Index.vue | 147 ++++++++++ resources/js/Pages/Dashboard/Main.vue | 2 +- 21 files changed, 1191 insertions(+), 16 deletions(-) create mode 100644 app/Containers/Dashboard/Actions/IntegrationCredentials/ManageIntegrationCredentialsAction.php create mode 100644 app/Containers/Dashboard/Models/IntegrationCredential.php create mode 100644 app/Containers/Dashboard/Tasks/IntegrationCredentials/DeleteIntegrationCredentialTask.php create mode 100644 app/Containers/Dashboard/Tasks/IntegrationCredentials/GetIntegrationCredentialTask.php create mode 100644 app/Containers/Dashboard/Tasks/IntegrationCredentials/ListIntegrationCredentialsTask.php create mode 100644 app/Containers/Dashboard/Tasks/IntegrationCredentials/SaveIntegrationCredentialTask.php create mode 100644 app/Containers/Dashboard/UI/WEB/Controllers/IntegrationCredentialsController.php create mode 100644 app/Containers/Dashboard/UI/WEB/Requests/StoreIntegrationCredentialRequest.php create mode 100644 app/Containers/Dashboard/UI/WEB/Requests/UpdateIntegrationCredentialRequest.php create mode 100644 database/migrations/2026_06_29_000000_create_integration_credentials_table.php create mode 100644 resources/js/Pages/Dashboard/Deploy/Index.vue create mode 100644 resources/js/Pages/Dashboard/IntegrationCredentials/Create.vue create mode 100644 resources/js/Pages/Dashboard/IntegrationCredentials/Edit.vue create mode 100644 resources/js/Pages/Dashboard/IntegrationCredentials/Index.vue diff --git a/app/Containers/Dashboard/Actions/IntegrationCredentials/ManageIntegrationCredentialsAction.php b/app/Containers/Dashboard/Actions/IntegrationCredentials/ManageIntegrationCredentialsAction.php new file mode 100644 index 0000000..ee0d985 --- /dev/null +++ b/app/Containers/Dashboard/Actions/IntegrationCredentials/ManageIntegrationCredentialsAction.php @@ -0,0 +1,47 @@ +listTask->run(); + } + + public function create(array $data): IntegrationCredential + { + return $this->saveTask->run( + $data['provider'], + $data['payload'], + $data['is_active'] ?? true, + ); + } + + public function update(IntegrationCredential $credential, array $data): IntegrationCredential + { + return $this->saveTask->run( + $data['provider'], + $data['payload'], + $data['is_active'] ?? true, + ); + } + + public function delete(IntegrationCredential $credential): bool + { + return $this->deleteTask->run($credential); + } +} diff --git a/app/Containers/Dashboard/Models/IntegrationCredential.php b/app/Containers/Dashboard/Models/IntegrationCredential.php new file mode 100644 index 0000000..e207f3e --- /dev/null +++ b/app/Containers/Dashboard/Models/IntegrationCredential.php @@ -0,0 +1,15 @@ + 'encrypted:array', + 'is_active' => 'boolean', + ]; +} diff --git a/app/Containers/Dashboard/Tasks/AI/CallAiServiceForFileSelectionTask.php b/app/Containers/Dashboard/Tasks/AI/CallAiServiceForFileSelectionTask.php index 94dd654..1dfe8ab 100644 --- a/app/Containers/Dashboard/Tasks/AI/CallAiServiceForFileSelectionTask.php +++ b/app/Containers/Dashboard/Tasks/AI/CallAiServiceForFileSelectionTask.php @@ -2,11 +2,16 @@ namespace App\Containers\Dashboard\Tasks\AI; +use App\Containers\Dashboard\Tasks\IntegrationCredentials\GetIntegrationCredentialTask; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class CallAiServiceForFileSelectionTask { + public function __construct( + private readonly GetIntegrationCredentialTask $getCredentialTask, + ) {} + /** * Отправляет фрагменты файлов в AI для определения основного файла * @@ -19,6 +24,15 @@ class CallAiServiceForFileSelectionTask 'fragments_count' => count($fragments), ]); + $credential = $this->getCredentialTask->run('qwen'); + if (!$credential) { + Log::error('[CallAiServiceForFileSelectionTask] API ключ не настроен', [ + 'provider' => 'qwen', + ]); + return null; + } + $apiKey = $credential->payload['api_key'] ?? ''; + $prompt = $this->buildPrompt($fragments); Log::info('[CallAiServiceForFileSelectionTask] Отправка запроса к AI'); @@ -26,7 +40,7 @@ class CallAiServiceForFileSelectionTask try { $response = Http::timeout(30) // Таймаут 30 секунд ->withHeaders([ - 'Authorization' => 'Bearer ' . env('QWEN_API_KEY'), + 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', ])->post('https://routerai.ru/api/v1/chat/completions', [ 'model' => 'qwen/qwen3.5-flash-02-23', diff --git a/app/Containers/Dashboard/Tasks/AI/CallAiServiceTask.php b/app/Containers/Dashboard/Tasks/AI/CallAiServiceTask.php index ce948cb..438c567 100644 --- a/app/Containers/Dashboard/Tasks/AI/CallAiServiceTask.php +++ b/app/Containers/Dashboard/Tasks/AI/CallAiServiceTask.php @@ -2,12 +2,17 @@ namespace App\Containers\Dashboard\Tasks\AI; +use App\Containers\Dashboard\Tasks\IntegrationCredentials\GetIntegrationCredentialTask; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class CallAiServiceTask { + public function __construct( + private readonly GetIntegrationCredentialTask $getCredentialTask, + ) {} + /** * Отправляет текст в AI сервис для извлечения структурированных данных * @@ -23,14 +28,14 @@ class CallAiServiceTask 'categories_count' => $categories->count(), ]); - // Проверяем наличие API ключа - $apiKey = env('QWEN_API_KEY'); - if (empty($apiKey)) { + $credential = $this->getCredentialTask->run('qwen'); + if (!$credential) { Log::error('[CallAiServiceTask] API ключ не настроен', [ - 'env_key' => 'QWEN_API_KEY', + 'provider' => 'qwen', ]); - throw new \RuntimeException('AI API ключ не настроен в окружении'); + throw new \RuntimeException('AI API ключ не настроен'); } + $apiKey = $credential->payload['api_key'] ?? ''; $categoryList = collect($categories) ->map(fn($cat) => "{$cat->id}: {$cat->title}") diff --git a/app/Containers/Dashboard/Tasks/IntegrationCredentials/DeleteIntegrationCredentialTask.php b/app/Containers/Dashboard/Tasks/IntegrationCredentials/DeleteIntegrationCredentialTask.php new file mode 100644 index 0000000..f8f9172 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/IntegrationCredentials/DeleteIntegrationCredentialTask.php @@ -0,0 +1,20 @@ +provider; + $deleted = $credential->delete(); + + Cache::forget(CacheKeys::INTEGRATION_CREDENTIAL_PREFIX->value . $provider); + + return $deleted; + } +} diff --git a/app/Containers/Dashboard/Tasks/IntegrationCredentials/GetIntegrationCredentialTask.php b/app/Containers/Dashboard/Tasks/IntegrationCredentials/GetIntegrationCredentialTask.php new file mode 100644 index 0000000..60ef853 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/IntegrationCredentials/GetIntegrationCredentialTask.php @@ -0,0 +1,21 @@ +value . $provider; + + return Cache::remember($cacheKey, now()->addHour(), function () use ($provider) { + return IntegrationCredential::where('provider', $provider) + ->where('is_active', true) + ->first(); + }); + } +} diff --git a/app/Containers/Dashboard/Tasks/IntegrationCredentials/ListIntegrationCredentialsTask.php b/app/Containers/Dashboard/Tasks/IntegrationCredentials/ListIntegrationCredentialsTask.php new file mode 100644 index 0000000..9695619 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/IntegrationCredentials/ListIntegrationCredentialsTask.php @@ -0,0 +1,14 @@ +get(); + } +} diff --git a/app/Containers/Dashboard/Tasks/IntegrationCredentials/SaveIntegrationCredentialTask.php b/app/Containers/Dashboard/Tasks/IntegrationCredentials/SaveIntegrationCredentialTask.php new file mode 100644 index 0000000..ccb6f1b --- /dev/null +++ b/app/Containers/Dashboard/Tasks/IntegrationCredentials/SaveIntegrationCredentialTask.php @@ -0,0 +1,22 @@ + $provider], + ['payload' => $payload, 'is_active' => $isActive] + ); + + Cache::forget(CacheKeys::INTEGRATION_CREDENTIAL_PREFIX->value . $provider); + + return $credential; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/DeployController.php b/app/Containers/Dashboard/UI/WEB/Controllers/DeployController.php index 3b76455..343c04d 100644 --- a/app/Containers/Dashboard/UI/WEB/Controllers/DeployController.php +++ b/app/Containers/Dashboard/UI/WEB/Controllers/DeployController.php @@ -6,6 +6,7 @@ use App\Containers\Dashboard\Actions\Posts\DeploySiteAction; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use Inertia\Inertia; class DeployController extends Controller { @@ -13,9 +14,17 @@ class DeployController extends Controller private readonly DeploySiteAction $deploySiteAction, ) {} - /** - * Запускает деплой (создаёт файл-триггер) - */ + public function index(): \Inertia\Response + { + $history = $this->deploySiteAction->getHistory(); + $status = $this->deploySiteAction->getStatus(); + + return Inertia::render('Dashboard/Deploy/Index', [ + 'history' => $history['history'], + 'status' => $status, + ]); + } + public function deploy(Request $request): JsonResponse { if (app()->environment() !== 'production') { @@ -37,9 +46,6 @@ class DeployController extends Controller return response()->json($result); } - /** - * Проверяет статус деплоя - */ public function status(Request $request): JsonResponse { if (app()->environment() !== 'production') { @@ -58,9 +64,29 @@ class DeployController extends Controller return response()->json($status); } - /** - * Очищает статус деплоя - */ + public function log(Request $request): JsonResponse + { + if (!$request->user()->hasRole('super_admin')) { + return response()->json(['success' => false], 403); + } + + $lines = (int) $request->query('lines', 50); + $result = $this->deploySiteAction->getLog($lines); + + return response()->json($result); + } + + public function history(Request $request): JsonResponse + { + if (!$request->user()->hasRole('super_admin')) { + return response()->json(['success' => false], 403); + } + + $result = $this->deploySiteAction->getHistory(); + + return response()->json($result); + } + public function clear(Request $request): JsonResponse { if (!$request->user()->hasRole('super_admin')) { diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/IntegrationCredentialsController.php b/app/Containers/Dashboard/UI/WEB/Controllers/IntegrationCredentialsController.php new file mode 100644 index 0000000..6320e8c --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/IntegrationCredentialsController.php @@ -0,0 +1,78 @@ + $this->action->list(), + ]); + } + + public function create(): \Inertia\Response + { + return Inertia::render('Dashboard/IntegrationCredentials/Create'); + } + + public function store(StoreIntegrationCredentialRequest $request): RedirectResponse + { + try { + $this->action->create($request->validated()); + + return redirect()->route('dashboard.integration-credentials.index') + ->with('success', 'Провайдер успешно добавлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при добавлении: ' . $e->getMessage()); + } + } + + public function edit(IntegrationCredential $credential): \Inertia\Response + { + return Inertia::render('Dashboard/IntegrationCredentials/Edit', [ + 'credential' => $credential, + ]); + } + + public function update(UpdateIntegrationCredentialRequest $request, IntegrationCredential $credential): RedirectResponse + { + try { + $this->action->update($credential, $request->validated()); + + return redirect()->route('dashboard.integration-credentials.index') + ->with('success', 'Провайдер успешно обновлён!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении: ' . $e->getMessage()); + } + } + + public function destroy(IntegrationCredential $credential): RedirectResponse + { + try { + $this->action->delete($credential); + + return redirect()->route('dashboard.integration-credentials.index') + ->with('success', 'Провайдер успешно удалён!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreIntegrationCredentialRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreIntegrationCredentialRequest.php new file mode 100644 index 0000000..2b50d65 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreIntegrationCredentialRequest.php @@ -0,0 +1,23 @@ + ['required', 'string', 'max:255', 'unique:integration_credentials,provider'], + 'payload' => ['required', 'array'], + 'payload.*' => ['required', 'string'], + 'is_active' => ['boolean'], + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateIntegrationCredentialRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateIntegrationCredentialRequest.php new file mode 100644 index 0000000..91f13b4 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateIntegrationCredentialRequest.php @@ -0,0 +1,23 @@ + ['required', 'string', 'max:255', 'unique:integration_credentials,provider,' . $this->route('credential')->id], + 'payload' => ['required', 'array'], + 'payload.*' => ['required', 'string'], + 'is_active' => ['boolean'], + ]; + } +} diff --git a/database/migrations/2026_06_29_000000_create_integration_credentials_table.php b/database/migrations/2026_06_29_000000_create_integration_credentials_table.php new file mode 100644 index 0000000..4ab5969 --- /dev/null +++ b/database/migrations/2026_06_29_000000_create_integration_credentials_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('provider')->unique(); + $table->text('payload'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_credentials'); + } +}; diff --git a/resources/js/Pages/Dashboard/Components/SidebarNavItem.vue b/resources/js/Pages/Dashboard/Components/SidebarNavItem.vue index 1dbb77c..1e8fc34 100644 --- a/resources/js/Pages/Dashboard/Components/SidebarNavItem.vue +++ b/resources/js/Pages/Dashboard/Components/SidebarNavItem.vue @@ -76,6 +76,7 @@ const iconMap = { 'arrow-path': 'arrow-path', 'academic-cap': 'academic-cap', 'clipboard-document-check': 'clipboard-document-check', + key: 'key', }; const iconComponent = computed(() => iconMap[props.item.icon] || 'home'); diff --git a/resources/js/Pages/Dashboard/Components/menuConfig.js b/resources/js/Pages/Dashboard/Components/menuConfig.js index cedc931..6b86588 100644 --- a/resources/js/Pages/Dashboard/Components/menuConfig.js +++ b/resources/js/Pages/Dashboard/Components/menuConfig.js @@ -121,6 +121,13 @@ export const menuItems = [ { label: 'Все пользователи', route: 'dashboard.users.index' }, ], }, + { + key: 'deploy', + label: 'Деплой', + icon: 'arrow-up-tray', + route: 'dashboard.deploy.index', + activePrefixes: ['dashboard.deploy'], + }, { key: 'vikon-updates', label: 'Обновления VIKON', @@ -128,4 +135,11 @@ export const menuItems = [ route: 'dashboard.vikon-updates.index', activePrefixes: ['dashboard.vikon-updates'], }, + { + key: 'integration-credentials', + label: 'Интеграционные ключи', + icon: 'key', + route: 'dashboard.integration-credentials.index', + activePrefixes: ['dashboard.integration-credentials'], + }, ]; diff --git a/resources/js/Pages/Dashboard/Components/shared/QuickActions.vue b/resources/js/Pages/Dashboard/Components/shared/QuickActions.vue index 23b1130..87b22be 100644 --- a/resources/js/Pages/Dashboard/Components/shared/QuickActions.vue +++ b/resources/js/Pages/Dashboard/Components/shared/QuickActions.vue @@ -76,6 +76,16 @@ export default { textClass: 'group-hover:text-amber-600', hoverClass: 'hover:border-amber-500/20 hover:bg-amber-500/5', }, + { + route: 'dashboard.sveden', + label: 'Обновить Sveden', + desc: 'Заглушка для обновления', + icon: 'arrow-path', + bgClass: 'bg-cyan-500/10 group-hover:bg-cyan-500/20', + iconClass: 'text-cyan-600', + textClass: 'group-hover:text-cyan-600', + hoverClass: 'hover:border-cyan-500/20 hover:bg-cyan-500/5', + }, ], }; }, diff --git a/resources/js/Pages/Dashboard/Deploy/Index.vue b/resources/js/Pages/Dashboard/Deploy/Index.vue new file mode 100644 index 0000000..621589d --- /dev/null +++ b/resources/js/Pages/Dashboard/Deploy/Index.vue @@ -0,0 +1,267 @@ + + + diff --git a/resources/js/Pages/Dashboard/IntegrationCredentials/Create.vue b/resources/js/Pages/Dashboard/IntegrationCredentials/Create.vue new file mode 100644 index 0000000..cd1938f --- /dev/null +++ b/resources/js/Pages/Dashboard/IntegrationCredentials/Create.vue @@ -0,0 +1,194 @@ + + + diff --git a/resources/js/Pages/Dashboard/IntegrationCredentials/Edit.vue b/resources/js/Pages/Dashboard/IntegrationCredentials/Edit.vue new file mode 100644 index 0000000..5ab96b8 --- /dev/null +++ b/resources/js/Pages/Dashboard/IntegrationCredentials/Edit.vue @@ -0,0 +1,210 @@ + + + diff --git a/resources/js/Pages/Dashboard/IntegrationCredentials/Index.vue b/resources/js/Pages/Dashboard/IntegrationCredentials/Index.vue new file mode 100644 index 0000000..575a424 --- /dev/null +++ b/resources/js/Pages/Dashboard/IntegrationCredentials/Index.vue @@ -0,0 +1,147 @@ + + + diff --git a/resources/js/Pages/Dashboard/Main.vue b/resources/js/Pages/Dashboard/Main.vue index 7888e01..db59fe8 100755 --- a/resources/js/Pages/Dashboard/Main.vue +++ b/resources/js/Pages/Dashboard/Main.vue @@ -22,7 +22,7 @@ type="button" @click="deploySite" :disabled="deploying" - class="inline-flex items-center gap-2 px-5 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md" + class="inline-flex items-center gap-2 px-5 py-2.5 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md" >