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
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue'
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue'
|
||||
|
||||
const props = defineProps({
|
||||
history: { type: Array, default: () => [] },
|
||||
status: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const deployStatus = ref(props.status)
|
||||
const deployLog = ref('')
|
||||
const isDeploying = ref(false)
|
||||
const isPolling = ref(false)
|
||||
let pollInterval = null
|
||||
|
||||
const statusColors = {
|
||||
idle: 'text-gray-500',
|
||||
running: 'text-blue-600',
|
||||
completed: 'text-green-600',
|
||||
failed: 'text-red-600',
|
||||
unknown: 'text-yellow-600',
|
||||
disabled: 'text-gray-400',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
idle: 'Ожидание',
|
||||
running: 'Выполняется',
|
||||
completed: 'Завершён',
|
||||
failed: 'Ошибка',
|
||||
unknown: 'Неизвестно',
|
||||
disabled: 'Отключено',
|
||||
}
|
||||
|
||||
const statusBgColors = {
|
||||
idle: 'bg-gray-100',
|
||||
running: 'bg-blue-100',
|
||||
completed: 'bg-green-100',
|
||||
failed: 'bg-red-100',
|
||||
unknown: 'bg-yellow-100',
|
||||
disabled: 'bg-gray-100',
|
||||
}
|
||||
|
||||
async function startDeploy() {
|
||||
if (!confirm('Запустить деплой? Приложение будет обновлено.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(route('dashboard.deploy'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
isDeploying.value = true
|
||||
deployStatus.value = { status: 'running', message: 'Деплой запущен...' }
|
||||
startPolling()
|
||||
} else {
|
||||
alert(data.message || 'Ошибка запуска деплоя')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Deploy error:', error)
|
||||
alert('Ошибка соединения с сервером')
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (isPolling.value) return
|
||||
isPolling.value = true
|
||||
|
||||
pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const statusRes = await fetch(route('dashboard.deploy.status'), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
const statusData = await statusRes.json()
|
||||
deployStatus.value = statusData
|
||||
|
||||
const logRes = await fetch(route('dashboard.deploy.log') + '?lines=100', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
const logData = await logRes.json()
|
||||
deployLog.value = logData.full_log || logData.log || ''
|
||||
|
||||
if (statusData.status !== 'running') {
|
||||
stopPolling()
|
||||
isDeploying.value = false
|
||||
router.reload({ only: ['history'] })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
isPolling.value = false
|
||||
}
|
||||
|
||||
async function clearLog() {
|
||||
try {
|
||||
await fetch(route('dashboard.deploy.clear'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
deployLog.value = ''
|
||||
deployStatus.value = { status: 'idle', message: 'Деплой не запущен' }
|
||||
} catch (error) {
|
||||
console.error('Clear error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (deployStatus.value.status === 'running') {
|
||||
startPolling()
|
||||
}
|
||||
if (deployStatus.value.log) {
|
||||
deployLog.value = deployStatus.value.log
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<FlashMessages />
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Деплой сайта</h1>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Управление обновлением сайта на production сервере
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<div class="bg-white shadow rounded-lg p-6">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">Статус</h3>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium',
|
||||
statusBgColors[deployStatus.status] || 'bg-gray-100',
|
||||
statusColors[deployStatus.status] || 'text-gray-500',
|
||||
]"
|
||||
>
|
||||
{{ statusLabels[deployStatus.status] || deployStatus.status }}
|
||||
</span>
|
||||
<p class="mt-2 text-sm text-gray-600">{{ deployStatus.message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">Действия</h3>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="startDeploy"
|
||||
:disabled="isDeploying || deployStatus.status === 'running'"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="isDeploying" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ isDeploying ? 'Выполняется...' : 'Запустить деплой' }}
|
||||
</button>
|
||||
<button
|
||||
@click="clearLog"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Очистить лог
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">Информация</h3>
|
||||
<dl class="space-y-1">
|
||||
<div class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500">Деплоев выполнено:</dt>
|
||||
<dd class="font-medium text-gray-900">{{ history.length }}</dd>
|
||||
</div>
|
||||
<div v-if="history.length > 0" class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500">Последний деплой:</dt>
|
||||
<dd class="font-medium text-gray-900">{{ formatDate(history[0]?.timestamp) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="deployLog" class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Лог деплоя</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 overflow-auto max-h-96 text-sm font-mono whitespace-pre-wrap">{{ deployLog }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">История деплоев</h3>
|
||||
</div>
|
||||
<div v-if="history.length === 0" class="p-6 text-center text-gray-500">
|
||||
Деплои ещё не выполнялись
|
||||
</div>
|
||||
<table v-else class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Дата</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Коммит</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Запущен</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="(item, index) in history" :key="index">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ formatDate(item.timestamp) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
item.status === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800',
|
||||
]"
|
||||
>
|
||||
{{ item.status === 'success' ? 'Успешно' : 'Ошибка' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">{{ item.commit }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ item.triggered_by }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-title>Добавление провайдера</template>
|
||||
<template #header-subtitle>Новый интеграционный ключ</template>
|
||||
|
||||
<FlashMessages />
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-layer-line">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="key" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Данные провайдера</h2>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground-1 mt-1">
|
||||
Укажите название сервиса и его ключи
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Provider Name -->
|
||||
<div>
|
||||
<label for="provider" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название провайдера <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="provider"
|
||||
v-model="form.provider"
|
||||
type="text"
|
||||
placeholder="Например: qwen, gemini, vk, smtp"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.provider ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.provider" class="mt-1.5 text-xs text-rose-500">{{ errors.provider }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Payload Key-Value Fields -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Ключи <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(field, index) in form.payloadFields"
|
||||
:key="index"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<input
|
||||
v-model="field.key"
|
||||
type="text"
|
||||
placeholder="Ключ (напр. api_key)"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors['payload.' + index + '.key'] ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
placeholder="Значение"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all font-mono',
|
||||
errors['payload.' + index + '.value'] ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeField(index)"
|
||||
class="p-2.5 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить поле"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="addField"
|
||||
class="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="plus" size="3" />
|
||||
Добавить поле
|
||||
</button>
|
||||
<p v-if="errors.payload" class="mt-1.5 text-xs text-rose-500">{{ errors.payload }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="is_active"
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-primary bg-surface border-layer-line rounded focus:ring-primary/20"
|
||||
/>
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активен
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-layer-line">
|
||||
<a
|
||||
:href="route('dashboard.integration-credentials.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 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"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Добавить провайдер' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'IntegrationCredentialCreate',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
provider: '',
|
||||
payloadFields: [{ key: '', value: '' }],
|
||||
is_active: true,
|
||||
},
|
||||
errors: {},
|
||||
processing: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Добавление провайдера');
|
||||
},
|
||||
methods: {
|
||||
addField() {
|
||||
this.form.payloadFields.push({ key: '', value: '' });
|
||||
},
|
||||
removeField(index) {
|
||||
if (this.form.payloadFields.length > 1) {
|
||||
this.form.payloadFields.splice(index, 1);
|
||||
}
|
||||
},
|
||||
buildPayload() {
|
||||
const payload = {};
|
||||
for (const field of this.form.payloadFields) {
|
||||
if (field.key.trim()) {
|
||||
payload[field.key.trim()] = field.value;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
const data = {
|
||||
provider: this.form.provider,
|
||||
payload: this.buildPayload(),
|
||||
is_active: this.form.is_active,
|
||||
};
|
||||
|
||||
this.$inertia.post(route('dashboard.integration-credentials.store'), data, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-title>Редактирование провайдера</template>
|
||||
<template #header-subtitle>{{ credential.provider }}</template>
|
||||
|
||||
<FlashMessages />
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-layer-line">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="key" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Данные провайдера</h2>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground-1 mt-1">
|
||||
Обновите ключи сервиса
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Provider Name -->
|
||||
<div>
|
||||
<label for="provider" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название провайдера <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="provider"
|
||||
v-model="form.provider"
|
||||
type="text"
|
||||
placeholder="Например: qwen, gemini, vk, smtp"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.provider ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.provider" class="mt-1.5 text-xs text-rose-500">{{ errors.provider }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Payload Key-Value Fields -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Ключи <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(field, index) in form.payloadFields"
|
||||
:key="index"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<input
|
||||
v-model="field.key"
|
||||
type="text"
|
||||
placeholder="Ключ (напр. api_key)"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors['payload.' + index + '.key'] ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
placeholder="Значение"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all font-mono',
|
||||
errors['payload.' + index + '.value'] ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeField(index)"
|
||||
class="p-2.5 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить поле"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="addField"
|
||||
class="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="plus" size="3" />
|
||||
Добавить поле
|
||||
</button>
|
||||
<p v-if="errors.payload" class="mt-1.5 text-xs text-rose-500">{{ errors.payload }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="is_active"
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-primary bg-surface border-layer-line rounded focus:ring-primary/20"
|
||||
/>
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активен
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-layer-line">
|
||||
<a
|
||||
:href="route('dashboard.integration-credentials.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 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"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'IntegrationCredentialEdit',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
},
|
||||
props: {
|
||||
credential: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
provider: this.credential.provider || '',
|
||||
payloadFields: this.buildPayloadFields(this.credential.payload),
|
||||
is_active: Boolean(this.credential.is_active),
|
||||
},
|
||||
errors: {},
|
||||
processing: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Редактирование провайдера');
|
||||
},
|
||||
methods: {
|
||||
buildPayloadFields(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return [{ key: '', value: '' }];
|
||||
}
|
||||
const fields = Object.entries(payload).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}));
|
||||
return fields.length > 0 ? fields : [{ key: '', value: '' }];
|
||||
},
|
||||
addField() {
|
||||
this.form.payloadFields.push({ key: '', value: '' });
|
||||
},
|
||||
removeField(index) {
|
||||
if (this.form.payloadFields.length > 1) {
|
||||
this.form.payloadFields.splice(index, 1);
|
||||
}
|
||||
},
|
||||
buildPayload() {
|
||||
const payload = {};
|
||||
for (const field of this.form.payloadFields) {
|
||||
if (field.key.trim()) {
|
||||
payload[field.key.trim()] = field.value;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
const data = {
|
||||
provider: this.form.provider,
|
||||
payload: this.buildPayload(),
|
||||
is_active: this.form.is_active,
|
||||
};
|
||||
|
||||
this.$inertia.put(route('dashboard.integration-credentials.update', this.credential.id), data, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="key" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Интеграционные ключи</template>
|
||||
<template #header-subtitle>Управление API-ключами сервисов</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.integration-credentials.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>
|
||||
</template>
|
||||
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Credentials Table -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-layer-line bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ credentials.length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-layer-line">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Провайдер
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Ключи
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Статус
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Дата создания
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-layer-line">
|
||||
<tr
|
||||
v-for="credential in credentials"
|
||||
:key="credential.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ credential.provider }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(value, key) in credential.payload"
|
||||
:key="key"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 bg-background-2 rounded text-xs text-muted-foreground-1"
|
||||
>
|
||||
<span class="font-medium">{{ key }}:</span>
|
||||
<span class="font-mono">{{ maskValue(value) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span :class="STATUS_BADGE_CLASS(credential.is_active)">
|
||||
{{ credential.is_active ? 'Активен' : 'Неактивен' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-foreground">{{ FORMAT_DATE(credential.created_at, 'short') }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.integration-credentials.edit', credential.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="CONFIRM_AND_DELETE(credential, 'dashboard.integration-credentials.destroy')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<EmptyState
|
||||
v-if="credentials.length === 0"
|
||||
:columns="5"
|
||||
title="Интеграционные ключи не найдены"
|
||||
description="Добавьте первый провайдер для настройки интеграций"
|
||||
:action-url="route('dashboard.integration-credentials.create')"
|
||||
action-text="Добавить провайдер"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
import EmptyState from '../Components/shared/EmptyState.vue';
|
||||
|
||||
export default {
|
||||
name: 'IntegrationCredentialsIndex',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
EmptyState,
|
||||
},
|
||||
props: {
|
||||
credentials: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Интеграционные ключи');
|
||||
},
|
||||
methods: {
|
||||
maskValue(value) {
|
||||
if (!value || value.length <= 8) return '••••••••';
|
||||
return value.substring(0, 4) + '••••' + value.substring(value.length - 4);
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -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"
|
||||
>
|
||||
<svg v-if="deploying" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
|
||||
Reference in New Issue
Block a user