R.I.P me........
This commit is contained in:
@@ -93,6 +93,23 @@
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.olapColumns') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="userStore.role === 'admin'"
|
||||
to="/olap-constructor"
|
||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||
:class="[
|
||||
route.path === '/olap-constructor' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||
]"
|
||||
:title="sidebarCollapsed ? 'OLAP Конструктор' : ''"
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6v12M16 6v12" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed" class="truncate">OLAP Конструктор</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="userStore.role === 'admin'"
|
||||
to="/database-connections"
|
||||
|
||||
@@ -11,6 +11,7 @@ import OlapColumnsView from '@/views/OlapColumnsView.vue'
|
||||
import DBConnections from '@/views/DBConnections.vue'
|
||||
import AdminSettings from '@/views/AdminSettings.vue'
|
||||
import Profile from '@/views/Profile.vue'
|
||||
import OLAPConstructor from '@/views/OLAPConstructor.vue'
|
||||
import NotFound from '@/views/NotFound.vue'
|
||||
|
||||
const routes = [
|
||||
@@ -63,6 +64,11 @@ const routes = [
|
||||
component: AdminSettings,
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
|
||||
},
|
||||
{
|
||||
path: '/olap-constructor',
|
||||
component: OLAPConstructor,
|
||||
meta: { requiresAuth: true, title: 'OLAP Constructor' }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
component: Profile,
|
||||
|
||||
938
frontend/src/views/OLAPConstructor.vue
Normal file
938
frontend/src/views/OLAPConstructor.vue
Normal file
@@ -0,0 +1,938 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex justify-between items-center mb-6 flex-wrap gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">OLAP Конструктор</h1>
|
||||
<div class="flex gap-3">
|
||||
<button @click="saveConfigAsJson" class="btn-secondary flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Сохранить JSON
|
||||
</button>
|
||||
<label class="btn-secondary flex items-center gap-2 cursor-pointer">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Загрузить JSON
|
||||
<input type="file" accept=".json" @change="loadConfigFromJson" class="hidden">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- Левая панель - поля (с прокруткой и сворачиванием) -->
|
||||
<div class="lg:w-72 card p-5 flex flex-col max-h-[calc(100vh-12rem)]">
|
||||
<h3 class="text-xl font-bold mb-4">Поля</h3>
|
||||
<div class="mb-3">
|
||||
<input type="text" v-model="searchQuery" placeholder="Поиск по названию или тегам" class="input-field">
|
||||
<div class="text-xs text-gray-500 mt-1" v-if="searchQuery">Найдено: {{ filteredAvailableFields.length }} / {{ availableFields.length }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-gray-500">Загрузка полей...</p>
|
||||
</div>
|
||||
<div v-else-if="error" class="bg-red-50 text-red-700 p-3 rounded-xl text-sm border border-red-200">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else-if="availableFields.length === 0" class="text-center py-8 text-gray-500">
|
||||
Нет доступных полей. Возможно, требуется инициализация структуры.
|
||||
</div>
|
||||
<div v-else class="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||
<!-- Числовые -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center cursor-pointer select-none" @click="toggleSection('number')">
|
||||
<h4 class="text-gray-600 text-sm font-bold">Числовые → VALUES</h4>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-400">{{ filteredNumberFields.length }}</span>
|
||||
<button class="text-gray-500 hover:text-gray-700">
|
||||
<svg v-if="collapsed.number" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!collapsed.number" class="mt-2">
|
||||
<div v-for="field in filteredNumberFields" :key="field.id"
|
||||
class="bg-purple-600 text-white p-3 rounded-xl mb-2 cursor-grab active:cursor-grabbing"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, field)"
|
||||
@dragend="dragEnd">
|
||||
{{ field.name }}
|
||||
<span class="text-[9px] bg-white/20 rounded-full px-1 ml-1">{{ field.tags?.slice(0,2).join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Категории -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center cursor-pointer select-none" @click="toggleSection('category')">
|
||||
<h4 class="text-gray-600 text-sm font-bold">Категории → ROW / COLUMN</h4>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-400">{{ filteredCategoryFields.length }}</span>
|
||||
<button class="text-gray-500 hover:text-gray-700">
|
||||
<svg v-if="collapsed.category" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!collapsed.category" class="mt-2">
|
||||
<div v-for="field in filteredCategoryFields" :key="field.id"
|
||||
class="bg-pink-500 text-white p-3 rounded-xl mb-2 cursor-grab active:cursor-grabbing"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, field)"
|
||||
@dragend="dragEnd">
|
||||
{{ field.name }}
|
||||
<span class="text-[9px] bg-white/20 rounded-full px-1 ml-1">{{ field.tags?.slice(0,2).join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center cursor-pointer select-none" @click="toggleSection('filter')">
|
||||
<h4 class="text-gray-600 text-sm font-bold">Фильтры</h4>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-400">{{ filteredFilterFields.length }}</span>
|
||||
<button class="text-gray-500 hover:text-gray-700">
|
||||
<svg v-if="collapsed.filter" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!collapsed.filter" class="mt-2">
|
||||
<div v-for="field in filteredFilterFields" :key="field.id"
|
||||
class="bg-cyan-600 text-white p-3 rounded-xl mb-2 cursor-grab active:cursor-grabbing"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, field)"
|
||||
@dragend="dragEnd">
|
||||
{{ field.name }}
|
||||
<span class="text-[9px] bg-white/20 rounded-full px-1 ml-1">{{ field.tags?.slice(0,2).join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 p-3 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<div class="flex justify-between"><span>ROW:</span><b>{{ rowFields.length }}</b></div>
|
||||
<div class="flex justify-between mt-1"><span>COLUMN:</span><b>{{ columnFields.length }}</b></div>
|
||||
<div class="flex justify-between mt-1"><span>VALUES:</span><b>{{ valueFields.length }}</b></div>
|
||||
<button @click="openResetModal" class="w-full mt-3 bg-gray-700 text-white py-2 rounded-lg hover:bg-gray-800 transition-colors">Сбросить всё</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая часть -->
|
||||
<div class="flex-1">
|
||||
<!-- Верхняя панель настроек -->
|
||||
<div class="card p-4 mb-4 flex flex-wrap items-center gap-4 justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-bold text-gray-700">Тип отчета:</span>
|
||||
<select v-model="reportType" class="input-field w-auto">
|
||||
<option value="SALES">SALES</option>
|
||||
<option value="DELIVERIES">DELIVERIES</option>
|
||||
<option value="TRANSACTIONS">TRANSACTIONS</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="buildSummary" class="w-4 h-4 rounded border-gray-300 text-primary-600">
|
||||
<span class="font-bold">buildSummary</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="active" class="w-4 h-4 rounded border-gray-300 text-primary-600">
|
||||
<span class="font-bold">Активно (UI)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Имя таблицы -->
|
||||
<div class="card p-4 mb-4">
|
||||
<label class="font-medium text-gray-700">Имя таблицы ClickHouse</label>
|
||||
<input type="text" v-model="tableName" @input="validateTableName"
|
||||
class="input-field mt-1"
|
||||
:class="{'border-green-500 bg-green-50': tableNameValid && tableName, 'border-red-500 bg-red-50': tableNameTouched && !tableNameValid && tableName}">
|
||||
<div v-if="tableNameTouched && tableName && !tableNameValid" class="text-red-500 text-sm mt-1">Должно начинаться с буквы</div>
|
||||
</div>
|
||||
|
||||
<!-- Настройки даты -->
|
||||
<div class="card p-4 mb-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="font-medium text-gray-700">Дата до (конец дня)</label>
|
||||
<input type="date" v-model="dateTo" class="input-field">
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-medium text-gray-700">Дней назад (≥1)</label>
|
||||
<input type="number" v-model.number="daysBack" min="1" step="1" class="input-field">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Вкладки -->
|
||||
<div class="card overflow-hidden">
|
||||
<div class="flex border-b border-gray-200">
|
||||
<button @click="activeTab='table'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="activeTab==='table' ? 'text-primary-600 border-b-2 border-primary-600' : 'text-gray-500 hover:text-gray-700'">
|
||||
Таблица
|
||||
</button>
|
||||
<button @click="activeTab='sql'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="activeTab==='sql' ? 'text-primary-600 border-b-2 border-primary-600' : 'text-gray-500 hover:text-gray-700'">
|
||||
SQL скрипт
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div v-if="activeTab==='table'">
|
||||
<!-- Фильтры -->
|
||||
<div class="mb-4"
|
||||
@dragover.prevent="dragOverZone = 'filter'"
|
||||
@dragleave="dragOverZone = null"
|
||||
@drop="dropOnZone('filter', $event)"
|
||||
:class="{'ring-2 ring-primary-400 bg-primary-50 rounded-lg': dragOverZone === 'filter'}">
|
||||
<h3 class="font-bold text-gray-800 mb-2">Пользовательские фильтры</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div v-for="(f, idx) in filterFields" :key="f.id"
|
||||
class="inline-flex items-center gap-2 bg-cyan-100 text-cyan-800 px-3 py-1 rounded-full text-sm">
|
||||
{{ f.name }}
|
||||
<div class="filter-control inline-flex gap-1 ml-1">
|
||||
<select v-model="f.filterType" class="text-xs bg-transparent border rounded px-1">
|
||||
<option value="IncludeValues">IncludeValues</option>
|
||||
<option value="ExcludeValues">ExcludeValues</option>
|
||||
<option value="EnumValue">EnumValue</option>
|
||||
<option value="StringValue">StringValue</option>
|
||||
</select>
|
||||
<template v-if="f.filterType === 'IncludeValues' || f.filterType === 'ExcludeValues'">
|
||||
<input type="text" v-model="f.valuesString" placeholder="знач1,знач2" class="text-xs border rounded px-1 w-28" @input="parseValues(f)">
|
||||
</template>
|
||||
<template v-else-if="f.filterType === 'EnumValue'">
|
||||
<input v-model="f.enumKey" placeholder="enumKey" class="text-xs border rounded px-1 w-20">
|
||||
<input v-model="f.enumValue" placeholder="значение" class="text-xs border rounded px-1 w-24">
|
||||
</template>
|
||||
<template v-else-if="f.filterType === 'StringValue'">
|
||||
<input v-model="f.value" placeholder="значение" class="text-xs border rounded px-1 w-24">
|
||||
</template>
|
||||
</div>
|
||||
<button @click="removeFilter(idx)" class="text-cyan-600 hover:text-cyan-800 ml-1">✕</button>
|
||||
</div>
|
||||
<div v-if="!filterFields.length" class="text-gray-400 text-sm">Перетащите поле фильтра</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Таблица-сетка -->
|
||||
<div class="overflow-x-auto border rounded-xl">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg-gray-100 p-2 text-center border-r border-gray-200"
|
||||
:colspan="rowFields.length || 1"
|
||||
@dragover.prevent="dragOverZone = 'row'"
|
||||
@dragleave="dragOverZone = null"
|
||||
@drop="dropOnZone('row', $event)"
|
||||
:class="{'bg-primary-50': dragOverZone === 'row'}">
|
||||
<div class="text-sm font-bold text-gray-600">ROW</div>
|
||||
<div class="flex flex-wrap justify-center gap-1 mt-1">
|
||||
<div v-for="(f, idx) in rowFields" :key="f.id"
|
||||
class="inline-flex items-center gap-1 bg-pink-100 text-pink-800 px-2 py-0.5 rounded-full text-xs cursor-move"
|
||||
draggable="true"
|
||||
@dragstart="dragStartReorder($event, f, 'row', idx)"
|
||||
@dragend="dragEndReorder"
|
||||
@dragover.prevent="dragOverReorder($event, 'row', idx)"
|
||||
@dragleave="dragLeaveReorder"
|
||||
@drop="dropReorder($event, 'row', idx)">
|
||||
{{ f.name }}
|
||||
<button @click="removeRow(idx)" class="text-pink-600 hover:text-pink-800">✕</button>
|
||||
</div>
|
||||
<span v-if="!rowFields.length" class="text-gray-400 text-xs">Перетащите категорию</span>
|
||||
</div>
|
||||
</th>
|
||||
<th class="bg-gray-100 p-2 text-center"
|
||||
:colspan="columnFields.length + (valueFields.length || 1)"
|
||||
@dragover.prevent="dragOverZone = 'column'"
|
||||
@dragleave="dragOverZone = null"
|
||||
@drop="dropOnZone('column', $event)"
|
||||
:class="{'bg-primary-50': dragOverZone === 'column'}">
|
||||
<div class="text-sm font-bold text-gray-600">COLUMN</div>
|
||||
<div class="flex flex-wrap justify-center gap-1 mt-1">
|
||||
<div v-for="(f, idx) in columnFields" :key="f.id"
|
||||
class="inline-flex items-center gap-1 bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full text-xs cursor-move"
|
||||
draggable="true"
|
||||
@dragstart="dragStartReorder($event, f, 'column', idx)"
|
||||
@dragend="dragEndReorder"
|
||||
@dragover.prevent="dragOverReorder($event, 'column', idx)"
|
||||
@dragleave="dragLeaveReorder"
|
||||
@drop="dropReorder($event, 'column', idx)">
|
||||
{{ f.name }}
|
||||
<button @click="removeColumn(idx)" class="text-blue-600 hover:text-blue-800">✕</button>
|
||||
</div>
|
||||
<span v-if="!columnFields.length" class="text-gray-400 text-xs">Перетащите категорию</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th v-for="rf in rowFields" :key="'rh'+rf.id" class="bg-gray-50 p-2 text-left text-xs font-medium text-gray-500 border-r">{{ rf.name }}</th>
|
||||
<th v-for="cf in columnFields" :key="'ch'+cf.id" class="bg-gray-50 p-2 text-left text-xs font-medium text-gray-500">{{ cf.name }}</th>
|
||||
<th class="bg-gray-100 p-2 text-center"
|
||||
:colspan="valueFields.length || 1"
|
||||
@dragover.prevent="dragOverZone = 'value'"
|
||||
@dragleave="dragOverZone = null"
|
||||
@drop="dropOnZone('value', $event)"
|
||||
:class="{'bg-primary-50': dragOverZone === 'value'}">
|
||||
<div class="text-sm font-bold text-gray-600">VALUES</div>
|
||||
<div class="flex flex-wrap justify-center gap-1 mt-1">
|
||||
<div v-for="(f, idx) in valueFields" :key="f.id"
|
||||
class="inline-flex items-center gap-1 bg-amber-100 text-amber-800 px-2 py-0.5 rounded-full text-xs cursor-move"
|
||||
draggable="true"
|
||||
@dragstart="dragStartReorder($event, f, 'value', idx)"
|
||||
@dragend="dragEndReorder"
|
||||
@dragover.prevent="dragOverReorder($event, 'value', idx)"
|
||||
@dragleave="dragLeaveReorder"
|
||||
@drop="dropReorder($event, 'value', idx)">
|
||||
{{ f.name }}
|
||||
<select v-model="f.aggregation" class="text-xs bg-transparent border rounded px-1" @click.stop>
|
||||
<option value="sum">SUM</option>
|
||||
<option value="avg">AVG</option>
|
||||
<option value="count">COUNT</option>
|
||||
</select>
|
||||
<button @click="removeValue(idx)" class="text-amber-600 hover:text-amber-800">✕</button>
|
||||
</div>
|
||||
<span v-if="!valueFields.length" class="text-gray-400 text-xs">Перетащите число</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in 3" :key="i" class="border-t">
|
||||
<td v-for="rf in rowFields" :key="'rv'+rf.id" class="p-2 text-sm text-gray-400 border-r">—</td>
|
||||
<td v-for="cf in columnFields" :key="'cv'+cf.id" class="p-2 text-sm text-gray-400">—</td>
|
||||
<td v-for="vf in valueFields" :key="'vv'+vf.id" class="p-2 text-sm font-bold text-purple-700">—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab==='sql'">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="font-bold text-gray-800">Скрипт для ClickHouse</h3>
|
||||
<button @click="copySQL" class="btn-secondary text-sm">Копировать SQL</button>
|
||||
</div>
|
||||
<div class="bg-gray-900 text-gray-200 p-4 rounded-xl overflow-x-auto font-mono text-sm whitespace-pre-wrap">
|
||||
<pre>{{ sqlScript }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модалка подтверждения сброса -->
|
||||
<Transition name="fade">
|
||||
<div v-if="resetModal.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="resetModal.show = false">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||
<div class="p-6 text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Сброс всех настроек</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">Вы уверены? Все выбранные поля, фильтры настройки будут удалены.</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="resetModal.show = false" class="btn-secondary">Отмена</button>
|
||||
<button @click="confirmReset" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue'
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
|
||||
const { showNotification } = useNotification()
|
||||
|
||||
// Типы
|
||||
interface ApiColumn {
|
||||
fieldKey: string
|
||||
fieldKeyNormal: string
|
||||
name: string
|
||||
aggregationAllowed: boolean
|
||||
groupingAllowed: boolean
|
||||
filteringAllowed: boolean
|
||||
tags: string[]
|
||||
reportTypes: string[]
|
||||
}
|
||||
|
||||
interface BaseField {
|
||||
id: string
|
||||
fieldKey: string
|
||||
fieldKeyNormal: string
|
||||
name: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface NumberField extends BaseField {
|
||||
role: 'number'
|
||||
aggregation: 'sum' | 'avg' | 'count'
|
||||
}
|
||||
|
||||
interface CategoryField extends BaseField {
|
||||
role: 'category'
|
||||
}
|
||||
|
||||
interface FilterField extends BaseField {
|
||||
role: 'filter'
|
||||
filterType: 'IncludeValues' | 'ExcludeValues' | 'EnumValue' | 'StringValue'
|
||||
valuesString: string
|
||||
values: string[]
|
||||
enumKey: string
|
||||
enumValue: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type AvailableField = NumberField | CategoryField | FilterField
|
||||
|
||||
interface IikoConfigFilter {
|
||||
filterType: string
|
||||
values?: string[]
|
||||
enumKey?: string
|
||||
enumValue?: string
|
||||
value?: string
|
||||
from?: string
|
||||
to?: string
|
||||
periodType?: string
|
||||
}
|
||||
|
||||
interface IikoConfig {
|
||||
reportType: string
|
||||
buildSummary: boolean
|
||||
groupByRowFields: string[]
|
||||
groupByColFields: string[]
|
||||
aggregateFields: string[]
|
||||
filters: Record<string, IikoConfigFilter>
|
||||
}
|
||||
|
||||
// Refs
|
||||
const columnsData = ref<ApiColumn[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const rowFields = ref<CategoryField[]>([])
|
||||
const columnFields = ref<CategoryField[]>([])
|
||||
const valueFields = ref<NumberField[]>([])
|
||||
const filterFields = ref<FilterField[]>([])
|
||||
const availableFields = ref<AvailableField[]>([])
|
||||
|
||||
const dateTo = ref('')
|
||||
const daysBack = ref(7)
|
||||
const active = ref(true)
|
||||
const tableName = ref('')
|
||||
const tableNameValid = ref(true)
|
||||
const tableNameTouched = ref(false)
|
||||
const reportType = ref<'SALES' | 'DELIVERIES' | 'TRANSACTIONS'>('SALES')
|
||||
const buildSummary = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const activeTab = ref<'table' | 'sql'>('table')
|
||||
|
||||
// Состояние сворачивания секций
|
||||
const collapsed = ref({
|
||||
number: false,
|
||||
category: false,
|
||||
filter: false
|
||||
})
|
||||
|
||||
// Метод переключения
|
||||
const toggleSection = (section: 'number' | 'category' | 'filter') => {
|
||||
collapsed.value[section] = !collapsed.value[section]
|
||||
}
|
||||
|
||||
// Модалка сброса
|
||||
const resetModal = ref({ show: false })
|
||||
|
||||
// Drag & drop state
|
||||
let draggedItem: AvailableField | null = null
|
||||
let draggedFromSidebar = true
|
||||
const dragOverZone = ref<string | null>(null)
|
||||
let dragReorderItem: CategoryField | NumberField | null = null
|
||||
let dragReorderType: string | null = null
|
||||
let dragReorderFromIdx: number | null = null
|
||||
|
||||
// Функции работы с полями
|
||||
const buildAvailableFields = (): AvailableField[] => {
|
||||
if (!columnsData.value.length) return []
|
||||
const selected = reportType.value
|
||||
const result: AvailableField[] = []
|
||||
for (const col of columnsData.value) {
|
||||
const okForReport = !col.reportTypes || col.reportTypes.length === 0 || col.reportTypes.includes(selected)
|
||||
if (!okForReport) continue
|
||||
|
||||
if (col.aggregationAllowed) {
|
||||
result.push({
|
||||
id: `${col.fieldKey}_number`,
|
||||
fieldKey: col.fieldKey,
|
||||
fieldKeyNormal: col.fieldKeyNormal,
|
||||
name: col.name,
|
||||
role: 'number',
|
||||
tags: col.tags || [],
|
||||
aggregation: 'sum'
|
||||
} as NumberField)
|
||||
}
|
||||
if (col.groupingAllowed) {
|
||||
result.push({
|
||||
id: `${col.fieldKey}_category`,
|
||||
fieldKey: col.fieldKey,
|
||||
fieldKeyNormal: col.fieldKeyNormal,
|
||||
name: col.name,
|
||||
role: 'category',
|
||||
tags: col.tags || []
|
||||
} as CategoryField)
|
||||
}
|
||||
if (col.filteringAllowed) {
|
||||
result.push({
|
||||
id: `${col.fieldKey}_filter`,
|
||||
fieldKey: col.fieldKey,
|
||||
fieldKeyNormal: col.fieldKeyNormal,
|
||||
name: col.name,
|
||||
role: 'filter',
|
||||
tags: col.tags || []
|
||||
} as FilterField)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const refreshFieldsAndReset = () => {
|
||||
availableFields.value = buildAvailableFields()
|
||||
rowFields.value = []
|
||||
columnFields.value = []
|
||||
valueFields.value = []
|
||||
filterFields.value = []
|
||||
}
|
||||
|
||||
const fetchColumns = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await fetch('/api/reports/olap/columns')
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const data = await response.json()
|
||||
columnsData.value = data.columns || []
|
||||
refreshFieldsAndReset()
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
error.value = `Ошибка загрузки полей: ${err.message}`
|
||||
showNotification(error.value, 'error')
|
||||
columnsData.value = []
|
||||
refreshFieldsAndReset()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(reportType, () => {
|
||||
refreshFieldsAndReset()
|
||||
})
|
||||
|
||||
const matchesSearch = (f: AvailableField): boolean => {
|
||||
if (!searchQuery.value.trim()) return true
|
||||
const terms = searchQuery.value.toLowerCase().split(/\s+/)
|
||||
const nameMatch = terms.some(t => f.name.toLowerCase().includes(t))
|
||||
const tagsMatch = f.tags ? terms.some(t => f.tags.some(tag => tag.toLowerCase().includes(t))) : false
|
||||
return nameMatch || tagsMatch
|
||||
}
|
||||
|
||||
const filteredAvailableFields = computed(() => availableFields.value.filter(matchesSearch))
|
||||
const filteredNumberFields = computed(() => availableFields.value.filter((f): f is NumberField => f.role === 'number' && matchesSearch(f)))
|
||||
const filteredCategoryFields = computed(() => availableFields.value.filter((f): f is CategoryField => f.role === 'category' && matchesSearch(f)))
|
||||
const filteredFilterFields = computed(() => availableFields.value.filter((f): f is FilterField => f.role === 'filter' && matchesSearch(f)))
|
||||
|
||||
const validateTableName = () => {
|
||||
tableNameTouched.value = true
|
||||
if (!tableName.value) { tableNameValid.value = true; return }
|
||||
tableNameValid.value = /^[A-Za-zА-Яа-я]/.test(tableName.value)
|
||||
}
|
||||
|
||||
const parseValues = (f: FilterField) => {
|
||||
f.values = f.valuesString ? f.valuesString.split(',').map(s => s.trim()).filter(s => s) : []
|
||||
}
|
||||
|
||||
const createNewFilter = (src: FilterField): FilterField => ({
|
||||
...src,
|
||||
id: Date.now() + Math.random().toString(),
|
||||
filterType: 'IncludeValues',
|
||||
valuesString: '',
|
||||
values: [],
|
||||
enumKey: '',
|
||||
enumValue: '',
|
||||
value: ''
|
||||
})
|
||||
|
||||
const dragStart = (e: DragEvent, f: AvailableField) => {
|
||||
draggedItem = f
|
||||
draggedFromSidebar = true
|
||||
e.dataTransfer!.effectAllowed = 'copy'
|
||||
const target = e.target as HTMLElement
|
||||
target.classList.add('opacity-40')
|
||||
}
|
||||
|
||||
const dragEnd = (e: DragEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
target.classList.remove('opacity-40')
|
||||
draggedItem = null
|
||||
draggedFromSidebar = true
|
||||
dragOverZone.value = null
|
||||
}
|
||||
|
||||
const dropOnZone = (zone: string, e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
dragOverZone.value = null
|
||||
if (!draggedItem || !draggedFromSidebar) return
|
||||
const item = draggedItem
|
||||
if (zone === 'row' && item.role !== 'category') return
|
||||
if (zone === 'column' && item.role !== 'category') return
|
||||
if (zone === 'value' && item.role !== 'number') return
|
||||
if (zone === 'filter' && item.role !== 'filter') return
|
||||
|
||||
const idx = availableFields.value.findIndex(f => f.id === item.id)
|
||||
if (idx === -1) return
|
||||
availableFields.value.splice(idx, 1)
|
||||
|
||||
if (zone === 'filter') {
|
||||
const newField = createNewFilter(item as FilterField)
|
||||
filterFields.value.push(newField)
|
||||
} else if (zone === 'value') {
|
||||
const newField: NumberField = { ...(item as NumberField), id: Date.now() + Math.random().toString(), aggregation: 'sum' }
|
||||
valueFields.value.push(newField)
|
||||
} else {
|
||||
const newField = { ...item, id: Date.now() + Math.random().toString() } as CategoryField
|
||||
if (zone === 'row') rowFields.value.push(newField)
|
||||
else if (zone === 'column') columnFields.value.push(newField)
|
||||
}
|
||||
draggedItem = null
|
||||
}
|
||||
|
||||
const dragStartReorder = (e: DragEvent, f: CategoryField | NumberField, type: string, idx: number) => {
|
||||
e.dataTransfer!.effectAllowed = 'move'
|
||||
dragReorderItem = f
|
||||
dragReorderType = type
|
||||
dragReorderFromIdx = idx
|
||||
const target = e.target as HTMLElement
|
||||
target.classList.add('opacity-40')
|
||||
}
|
||||
|
||||
const dragEndReorder = (e: DragEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
target.classList.remove('opacity-40')
|
||||
dragReorderItem = null
|
||||
dragReorderType = null
|
||||
dragReorderFromIdx = null
|
||||
}
|
||||
|
||||
const dragOverReorder = (e: DragEvent, type: string, idx: number) => {
|
||||
e.preventDefault()
|
||||
if (dragReorderType !== type) return
|
||||
if (dragReorderFromIdx === idx) return
|
||||
const target = e.target as HTMLElement
|
||||
const chip = target.closest('.cursor-move') as HTMLElement
|
||||
if (chip) chip.classList.add('ring-2', 'ring-primary-400')
|
||||
}
|
||||
|
||||
const dragLeaveReorder = (e: DragEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const chip = target.closest('.cursor-move') as HTMLElement
|
||||
if (chip) chip.classList.remove('ring-2', 'ring-primary-400')
|
||||
}
|
||||
|
||||
const dropReorder = (e: DragEvent, type: string, toIdx: number) => {
|
||||
e.preventDefault()
|
||||
const target = e.target as HTMLElement
|
||||
const chip = target.closest('.cursor-move') as HTMLElement
|
||||
if (chip) chip.classList.remove('ring-2', 'ring-primary-400')
|
||||
if (dragReorderType !== type) return
|
||||
let arr: (CategoryField | NumberField)[]
|
||||
if (type === 'row') arr = rowFields.value
|
||||
else if (type === 'column') arr = columnFields.value
|
||||
else if (type === 'value') arr = valueFields.value
|
||||
else return
|
||||
const fromIdx = dragReorderFromIdx!
|
||||
const item = arr[fromIdx]
|
||||
arr.splice(fromIdx, 1)
|
||||
arr.splice(toIdx, 0, item)
|
||||
dragReorderItem = null
|
||||
dragReorderType = null
|
||||
dragReorderFromIdx = null
|
||||
}
|
||||
|
||||
const restoreFieldToAvailable = (field: AvailableField, expectedRole: string) => {
|
||||
const exists = availableFields.value.some(f => f.fieldKey === field.fieldKey && f.role === expectedRole)
|
||||
if (!exists) {
|
||||
const originalCol = columnsData.value.find(c => c.fieldKey === field.fieldKey)
|
||||
if (originalCol) {
|
||||
if (expectedRole === 'number' && originalCol.aggregationAllowed) {
|
||||
availableFields.value.push({
|
||||
id: `${field.fieldKey}_number`,
|
||||
fieldKey: field.fieldKey,
|
||||
fieldKeyNormal: originalCol.fieldKeyNormal,
|
||||
name: originalCol.name,
|
||||
role: 'number',
|
||||
tags: originalCol.tags || [],
|
||||
aggregation: 'sum'
|
||||
} as NumberField)
|
||||
} else if (expectedRole === 'category' && originalCol.groupingAllowed) {
|
||||
availableFields.value.push({
|
||||
id: `${field.fieldKey}_category`,
|
||||
fieldKey: field.fieldKey,
|
||||
fieldKeyNormal: originalCol.fieldKeyNormal,
|
||||
name: originalCol.name,
|
||||
role: 'category',
|
||||
tags: originalCol.tags || []
|
||||
} as CategoryField)
|
||||
} else if (expectedRole === 'filter' && originalCol.filteringAllowed) {
|
||||
availableFields.value.push({
|
||||
id: `${field.fieldKey}_filter`,
|
||||
fieldKey: field.fieldKey,
|
||||
fieldKeyNormal: originalCol.fieldKeyNormal,
|
||||
name: originalCol.name,
|
||||
role: 'filter',
|
||||
tags: originalCol.tags || []
|
||||
} as FilterField)
|
||||
}
|
||||
} else {
|
||||
availableFields.value.push({ ...field, id: `${field.fieldKey}_${expectedRole}` } as AvailableField)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeRow = (idx: number) => {
|
||||
const f = rowFields.value[idx]
|
||||
rowFields.value.splice(idx, 1)
|
||||
restoreFieldToAvailable(f, 'category')
|
||||
}
|
||||
|
||||
const removeColumn = (idx: number) => {
|
||||
const f = columnFields.value[idx]
|
||||
columnFields.value.splice(idx, 1)
|
||||
restoreFieldToAvailable(f, 'category')
|
||||
}
|
||||
|
||||
const removeValue = (idx: number) => {
|
||||
const f = valueFields.value[idx]
|
||||
valueFields.value.splice(idx, 1)
|
||||
restoreFieldToAvailable(f, 'number')
|
||||
}
|
||||
|
||||
const removeFilter = (idx: number) => {
|
||||
const f = filterFields.value[idx]
|
||||
filterFields.value.splice(idx, 1)
|
||||
restoreFieldToAvailable(f, 'filter')
|
||||
}
|
||||
|
||||
const openResetModal = () => {
|
||||
resetModal.value.show = true
|
||||
}
|
||||
|
||||
const confirmReset = () => {
|
||||
resetModal.value.show = false
|
||||
refreshFieldsAndReset()
|
||||
dateTo.value = ''
|
||||
daysBack.value = 7
|
||||
active.value = true
|
||||
tableName.value = ''
|
||||
tableNameValid.value = true
|
||||
tableNameTouched.value = false
|
||||
buildSummary.value = true
|
||||
searchQuery.value = ''
|
||||
showNotification('Все настройки сброшены', 'success')
|
||||
}
|
||||
|
||||
const saveConfigAsJson = () => {
|
||||
const userFilters: Record<string, IikoConfigFilter> = {}
|
||||
filterFields.value.forEach(f => {
|
||||
if (f.filterType === 'IncludeValues' || f.filterType === 'ExcludeValues') {
|
||||
userFilters[f.fieldKey] = { filterType: f.filterType, values: f.values || [] }
|
||||
} else if (f.filterType === 'EnumValue') {
|
||||
userFilters[f.fieldKey] = { filterType: 'EnumValue', enumKey: f.enumKey || '', enumValue: f.enumValue || '' }
|
||||
} else if (f.filterType === 'StringValue') {
|
||||
userFilters[f.fieldKey] = { filterType: 'StringValue', value: f.value || '' }
|
||||
}
|
||||
})
|
||||
|
||||
const getDateFilter = (): Record<string, IikoConfigFilter> => {
|
||||
let toDate = dateTo.value ? new Date(dateTo.value) : new Date()
|
||||
toDate.setHours(23, 59, 59, 999)
|
||||
let fromDate = new Date(toDate)
|
||||
const days = Math.max(1, daysBack.value || 1)
|
||||
fromDate.setDate(toDate.getDate() - days)
|
||||
fromDate.setHours(0, 0, 0, 0)
|
||||
const formatDateTime = (date: Date) => date.toISOString()
|
||||
const filterKey = reportType.value === 'TRANSACTIONS' ? 'DateTime.DateTyped' : 'OpenDate.Typed'
|
||||
return { [filterKey]: { filterType: "DateRange", periodType: "CUSTOM", from: formatDateTime(fromDate), to: formatDateTime(toDate) } }
|
||||
}
|
||||
|
||||
const systemFilters: Record<string, IikoConfigFilter> = {
|
||||
"DeletedWithWriteoff": { filterType: "ExcludeValues", values: ["DELETED_WITH_WRITEOFF", "DELETED_WITHOUT_WRITEOFF"] },
|
||||
"OrderDeleted": { filterType: "IncludeValues", values: ["NOT_DELETED"] }
|
||||
}
|
||||
const allFilters = { ...userFilters, ...getDateFilter(), ...systemFilters }
|
||||
|
||||
const config: IikoConfig = {
|
||||
reportType: reportType.value,
|
||||
buildSummary: buildSummary.value,
|
||||
groupByRowFields: rowFields.value.map(f => f.fieldKey),
|
||||
groupByColFields: columnFields.value.map(f => f.fieldKey),
|
||||
aggregateFields: valueFields.value.map(f => f.fieldKey),
|
||||
filters: allFilters
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `iiko_olap_${reportType.value.toLowerCase()}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showNotification('JSON для iiko API сохранён', 'success')
|
||||
}
|
||||
|
||||
const loadConfigFromJson = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const config: IikoConfig = JSON.parse(e.target?.result as string)
|
||||
if (config.reportType) reportType.value = config.reportType as 'SALES' | 'DELIVERIES' | 'TRANSACTIONS'
|
||||
if (typeof config.buildSummary === 'boolean') buildSummary.value = config.buildSummary
|
||||
|
||||
const filterKey = reportType.value === 'TRANSACTIONS' ? 'DateTime.DateTyped' : 'OpenDate.Typed'
|
||||
const dateFilter = config.filters?.[filterKey]
|
||||
if (dateFilter && dateFilter.filterType === 'DateRange') {
|
||||
if (dateFilter.to) {
|
||||
const toD = new Date(dateFilter.to)
|
||||
if (!isNaN(toD.getTime())) dateTo.value = toD.toISOString().split('T')[0]
|
||||
}
|
||||
if (dateFilter.from && dateFilter.to) {
|
||||
const fromD = new Date(dateFilter.from), toD = new Date(dateFilter.to)
|
||||
const diff = Math.ceil((toD.getTime() - fromD.getTime()) / (1000 * 3600 * 24))
|
||||
if (diff > 0) daysBack.value = diff
|
||||
}
|
||||
}
|
||||
|
||||
refreshFieldsAndReset()
|
||||
|
||||
const addFieldByKey = (zoneType: string, fieldKey: string, expectedRole: string, extra: Partial<NumberField> = {}) => {
|
||||
const found = availableFields.value.find(f => f.fieldKey === fieldKey && f.role === expectedRole)
|
||||
if (found) {
|
||||
const idx = availableFields.value.indexOf(found)
|
||||
availableFields.value.splice(idx, 1)
|
||||
const newField = { ...found, id: Date.now() + Math.random().toString(), ...extra } as AvailableField
|
||||
if (zoneType === 'row') rowFields.value.push(newField as CategoryField)
|
||||
else if (zoneType === 'column') columnFields.value.push(newField as CategoryField)
|
||||
else if (zoneType === 'value') valueFields.value.push(newField as NumberField)
|
||||
else if (zoneType === 'filter') filterFields.value.push(newField as FilterField)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.groupByRowFields) config.groupByRowFields.forEach(fk => addFieldByKey('row', fk, 'category'))
|
||||
if (config.groupByColFields) config.groupByColFields.forEach(fk => addFieldByKey('column', fk, 'category'))
|
||||
if (config.aggregateFields) config.aggregateFields.forEach(fk => addFieldByKey('value', fk, 'number', { aggregation: 'sum' }))
|
||||
|
||||
if (config.filters) {
|
||||
Object.entries(config.filters).forEach(([fk, filterDef]) => {
|
||||
if (fk === 'OpenDate.Typed' || fk === 'DateTime.DateTyped' || fk === 'DeletedWithWriteoff' || fk === 'OrderDeleted') return
|
||||
const availableFilter = availableFields.value.find(f => f.fieldKey === fk && f.role === 'filter') as FilterField | undefined
|
||||
if (availableFilter) {
|
||||
const idx = availableFields.value.indexOf(availableFilter)
|
||||
availableFields.value.splice(idx, 1)
|
||||
const newFilter = createNewFilter(availableFilter)
|
||||
newFilter.filterType = filterDef.filterType as FilterField['filterType']
|
||||
if (filterDef.filterType === 'IncludeValues' || filterDef.filterType === 'ExcludeValues') {
|
||||
newFilter.values = filterDef.values || []
|
||||
newFilter.valuesString = newFilter.values.join(', ')
|
||||
} else if (filterDef.filterType === 'EnumValue') {
|
||||
newFilter.enumKey = filterDef.enumKey || ''
|
||||
newFilter.enumValue = filterDef.enumValue || ''
|
||||
} else if (filterDef.filterType === 'StringValue') {
|
||||
newFilter.value = filterDef.value || ''
|
||||
}
|
||||
filterFields.value.push(newFilter)
|
||||
}
|
||||
})
|
||||
}
|
||||
showNotification('Конфигурация iiko загружена', 'success')
|
||||
} catch (err: any) {
|
||||
showNotification('Ошибка при загрузке JSON: ' + err.message, 'error')
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const sqlScript = computed(() => {
|
||||
const dateCol = reportType.value === 'TRANSACTIONS' ? 'DateTime_DateTyped' : 'OpenDate_Typed'
|
||||
const columns: { name: string; type: string }[] = [{ name: dateCol, type: 'Date' }]
|
||||
rowFields.value.forEach(rf => columns.push({ name: rf.fieldKeyNormal, type: 'String' }))
|
||||
columnFields.value.forEach(cf => columns.push({ name: cf.fieldKeyNormal, type: 'String' }))
|
||||
valueFields.value.forEach(vf => columns.push({ name: vf.fieldKeyNormal, type: 'Int64' }))
|
||||
if (columns.length === 1) columns.push({ name: 'dummy', type: 'String' })
|
||||
let table = tableName.value.trim() || 'olap_table'
|
||||
table = table.replace(/[^a-zA-Z0-9_]/g, '_')
|
||||
if (!table.match(/^[a-zA-Z_]/)) table = '_' + table
|
||||
const fullTable = `\`default\`.\`${table}\``
|
||||
const colDefs = columns.map(c => ` \`${c.name}\` ${c.type}`).join(',\n')
|
||||
const orderBy = `\`${dateCol}\``
|
||||
const colNames = columns.map(c => `\`${c.name}\``).join(', ')
|
||||
return `CREATE TABLE IF NOT EXISTS ${fullTable} (\n${colDefs}\n) ENGINE = ReplacingMergeTree()\nORDER BY (${orderBy})\nSETTINGS index_granularity = 8192;\n\nINSERT INTO ${fullTable} (${colNames}) VALUES\n`
|
||||
})
|
||||
|
||||
const copySQL = () => {
|
||||
navigator.clipboard.writeText(sqlScript.value)
|
||||
showNotification('SQL скрипт скопирован', 'success')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchColumns()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Стили для скроллбара внутри левой панели */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -42,38 +42,6 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /phpmyadmin/ {
|
||||
allow 80.68.9.83;
|
||||
allow 185.51.125.202;
|
||||
|
||||
# Локальные сети
|
||||
allow 192.168.0.0/16; # 192.168.0.0 - 192.168.255.255
|
||||
allow 10.0.0.0/8; # 10.0.0.0 - 10.255.255.255
|
||||
allow 172.16.0.0/12; # 172.16.0.0 - 172.31.255.255
|
||||
|
||||
allow fd00::/8; # IPv6 ULA (аналог приватных IPv4)
|
||||
allow fe80::/10; # IPv6 link-local
|
||||
|
||||
# Localhost
|
||||
allow 127.0.0.0/8; # 127.0.0.0 - 127.255.255.255
|
||||
allow ::1; # IPv6 localhost
|
||||
|
||||
# Docker сети (если используете)
|
||||
allow 172.17.0.0/16;
|
||||
allow 172.18.0.0/16;
|
||||
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:7102/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection keep-alive;
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/iiko-app.dev.xserver.su/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/iiko-app.dev.xserver.su/privkey.pem;
|
||||
|
||||
@@ -31,7 +31,7 @@ public class IikoHandler {
|
||||
log.error("Failed to initialize database", err);
|
||||
});
|
||||
|
||||
router.route("/api/reports/olap/*").handler(authHandler::requireAuth);
|
||||
// router.route("/api/reports/olap/*").handler(authHandler::requireAuth);
|
||||
router.get("/api/reports/olap/columns").handler(this::getColumns);
|
||||
router.delete("/api/reports/olap/columns/:fieldKey").handler(AdminHandler::requireAdmin).handler(this::deleteColumn);
|
||||
router.post("/api/reports/olap/initialize").handler(AdminHandler::requireAdmin).handler(this::postInitialize);
|
||||
|
||||
24
src/main/java/su/xserver/iikocon/test/CodeGenerator.java
Normal file
24
src/main/java/su/xserver/iikocon/test/CodeGenerator.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package su.xserver.iikocon.test;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
public class CodeGenerator {
|
||||
|
||||
// SecureRandom — это криптостойкий генератор случайных чисел.
|
||||
// Он обеспечивает непредсказуемость, что критично для безопасности.
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
/**
|
||||
* Генерирует шестизначный код подтверждения, соответствующий корпоративным стандартам.
|
||||
*
|
||||
* @return Строка, содержащая ровно 6 цифр. Первая цифра никогда не будет '0'.
|
||||
*/
|
||||
public static String generateCorporateCode() {
|
||||
// Генерируем число в диапазоне от 100 000 до 999 999.
|
||||
// Это гарантирует, что наш код всегда будет шестизначным.
|
||||
int code = SECURE_RANDOM.nextInt(900_000) + 100_000;
|
||||
return String.valueOf(code);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -17,5 +17,14 @@ public class DateRangeSetup {
|
||||
|
||||
System.out.println("dateFrom=" + formattedDateFrom);
|
||||
System.out.println("dateTo=" + formattedDateTo);
|
||||
|
||||
System.out.println("CodeGenerator=" + CodeGenerator.generateCorporateCode());
|
||||
System.out.println("CodeGenerator=" + CodeGenerator.generateCorporateCode());
|
||||
System.out.println("CodeGenerator=" + CodeGenerator.generateCorporateCode());
|
||||
System.out.println("CodeGenerator=" + CodeGenerator.generateCorporateCode());
|
||||
System.out.println("CodeGenerator=" + CodeGenerator.generateCorporateCode());
|
||||
System.out.println("CodeGenerator=" + CodeGenerator.generateCorporateCode());
|
||||
System.out.println("CodeGenerator=" + CodeGenerator.generateCorporateCode());
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
198
src/main/java/su/xserver/iikocon/test/TestMail.java
Normal file
198
src/main/java/su/xserver/iikocon/test/TestMail.java
Normal file
@@ -0,0 +1,198 @@
|
||||
package su.xserver.iikocon.test;
|
||||
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.ext.mail.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class TestMail {
|
||||
private static final Logger log = LoggerFactory.getLogger(TestMail.class);
|
||||
public static void main(String[] args) {
|
||||
Vertx vertx = Vertx.vertx();
|
||||
|
||||
MailConfig config = new MailConfig()
|
||||
.setHostname("mail.xserver.su")
|
||||
.setPort(465)
|
||||
.setSsl(true)
|
||||
.setStarttls(StartTLSOptions.REQUIRED)
|
||||
.setUsername("admin.app@xserver.su")
|
||||
.setPassword("SOLTiLS64TiL");
|
||||
|
||||
MailClient mailClient = MailClient.create(vertx, config);
|
||||
|
||||
// MailMessage message = new MailMessage()
|
||||
// .setFrom("bodrycraft@yandex.ru")
|
||||
// .setTo("danil.bodry@vk.com")
|
||||
// .setSubject("Hello from Vert.x")
|
||||
// .setText("This is a test email sent using Vert.x Mail Client!");
|
||||
|
||||
// String htmlBody = """
|
||||
// <html>
|
||||
// <body>
|
||||
// <h1>Заголовок письма</h1>
|
||||
// <p>Это основная часть письма с форматированием.</p>
|
||||
// <p>Вы можете использовать <strong>жирный</strong> текст,
|
||||
// <a href="https://example.com">ссылки</a> и другие элементы.</p>
|
||||
// <img src="cid:bannerImage" alt="Баннер"><br>
|
||||
// <p>С уважением,<br>Ваша команда</p>
|
||||
// </body>
|
||||
// </html>
|
||||
// """;
|
||||
// MailAttachment regularAttachment = MailAttachment.create()
|
||||
// .setData(Buffer.buffer("Это содержимое текстового файла."))
|
||||
// .setName("информация.txt")
|
||||
// .setContentType("text/plain");
|
||||
// MailAttachment inlineImage = MailAttachment.create()
|
||||
// .setData(Buffer.buffer()) // Здесь должны быть данные вашего изображения
|
||||
// .setContentType("image/png")
|
||||
// .setContentId("bannerImage"); // Этот ID и используется в теге <img src="cid:bannerImage">
|
||||
// MailMessage message = new MailMessage()
|
||||
// .setFrom("bodrycraft@yandex.ru")
|
||||
// .setTo("danil.bodry@vk.com")
|
||||
// .setSubject("Красивое письмо от Vert.x")
|
||||
// .setText("Это обычная текстовая версия письма.")
|
||||
// .setHtml(htmlBody)
|
||||
// .setAttachment(regularAttachment)
|
||||
// .setInlineAttachment(inlineImage);
|
||||
|
||||
// 2. Генерируем 6-значный код
|
||||
String verificationCode = CodeGenerator.generateCorporateCode();
|
||||
String recipientEmail = "danil.bodry@xserver.su";
|
||||
String userName = "Дорогой пользователь"; // можно подставить имя
|
||||
|
||||
// 3. HTML-шаблон письма
|
||||
String htmlBody = buildHtmlEmail(verificationCode, userName);
|
||||
|
||||
// 4. Текстовая версия (plain text)
|
||||
String textBody = String.format("""
|
||||
%s,
|
||||
|
||||
Ваш код подтверждения: %s
|
||||
|
||||
Для завершения входа введите этот код на странице авторизации.
|
||||
Код действителен в течение 10 минут.
|
||||
|
||||
Если вы не запрашивали вход, просто проигнорируйте это письмо.
|
||||
|
||||
С уважением,
|
||||
Команда поддержки
|
||||
""", userName, verificationCode);
|
||||
|
||||
// 5. Формируем письмо
|
||||
MailMessage message = new MailMessage()
|
||||
.setFrom("gallery@xserver.su")
|
||||
.setTo(recipientEmail)
|
||||
.setSubject("Ваш код для входа")
|
||||
// .setText(textBody)
|
||||
.setHtml(htmlBody);
|
||||
|
||||
|
||||
mailClient.sendMail(message)
|
||||
.onSuccess(res -> log.info("Success!"))
|
||||
.onFailure(err -> log.error("Error: {}", err.getMessage()));
|
||||
|
||||
|
||||
}
|
||||
|
||||
private static String buildHtmlEmail(String code, String userName) {
|
||||
return "<!DOCTYPE html>\n" +
|
||||
"<html>\n" +
|
||||
"<head>\n" +
|
||||
" <meta charset=\"UTF-8\">\n" +
|
||||
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n" +
|
||||
" <title>Код подтверждения</title>\n" +
|
||||
" <style>\n" +
|
||||
" * { margin: 0; padding: 0; box-sizing: border-box; }\n" +
|
||||
" body {\n" +
|
||||
" font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;\n" +
|
||||
" background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n" +
|
||||
" margin: 0;\n" +
|
||||
" padding: 20px;\n" +
|
||||
" }\n" +
|
||||
" .container {\n" +
|
||||
" max-width: 520px;\n" +
|
||||
" margin: 0 auto;\n" +
|
||||
" background: #ffffff;\n" +
|
||||
" border-radius: 32px;\n" +
|
||||
" box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);\n" +
|
||||
" overflow: hidden;\n" +
|
||||
" }\n" +
|
||||
" .header {\n" +
|
||||
" background: linear-gradient(120deg, #4f46e5, #7c3aed);\n" +
|
||||
" padding: 32px 20px;\n" +
|
||||
" text-align: center;\n" +
|
||||
" }\n" +
|
||||
" .header h1 { color: white; font-size: 28px; font-weight: 700; letter-spacing: -0.5px; margin: 0; }\n" +
|
||||
" .content { padding: 40px 32px; }\n" +
|
||||
" .greeting { font-size: 20px; font-weight: 600; color: #1e293b; margin-bottom: 16px; }\n" +
|
||||
" .message { color: #334155; font-size: 16px; line-height: 1.5; margin-bottom: 28px; }\n" +
|
||||
" .code-box {\n" +
|
||||
" background: #f8fafc;\n" +
|
||||
" border-radius: 20px;\n" +
|
||||
" padding: 24px;\n" +
|
||||
" text-align: center;\n" +
|
||||
" margin: 24px 0;\n" +
|
||||
" border: 1px solid #e2e8f0;\n" +
|
||||
" box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);\n" +
|
||||
" }\n" +
|
||||
" .code-label { font-size: 13px; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; color: #64748b; margin-bottom: 12px; }\n" +
|
||||
" .code {\n" +
|
||||
" font-family: 'SF Mono', 'Menlo', 'Monaco', 'Cascadia Code', monospace;\n" +
|
||||
" font-size: 48px;\n" +
|
||||
" font-weight: 800;\n" +
|
||||
" letter-spacing: 8px;\n" +
|
||||
" color: #1e293b;\n" +
|
||||
" background: white;\n" +
|
||||
" display: inline-block;\n" +
|
||||
" padding: 12px 24px;\n" +
|
||||
" border-radius: 16px;\n" +
|
||||
" box-shadow: 0 1px 3px rgba(0,0,0,0.1);\n" +
|
||||
" margin: 8px 0;\n" +
|
||||
" }\n" +
|
||||
" .expiry { font-size: 14px; color: #475569; background: #f1f5f9; display: inline-block; padding: 6px 14px; border-radius: 40px; margin-top: 12px; }\n" +
|
||||
" .button {\n" +
|
||||
" display: inline-block;\n" +
|
||||
" background: linear-gradient(90deg, #4f46e5, #7c3aed);\n" +
|
||||
" color: white;\n" +
|
||||
" text-decoration: none;\n" +
|
||||
" padding: 12px 28px;\n" +
|
||||
" border-radius: 40px;\n" +
|
||||
" font-weight: 600;\n" +
|
||||
" margin: 16px 0 8px;\n" +
|
||||
" box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);\n" +
|
||||
" }\n" +
|
||||
" .footer { text-align: center; padding: 24px 32px; background: #f8fafc; font-size: 12px; color: #64748b; border-top: 1px solid #e2e8f0; }\n" +
|
||||
" .footer a { color: #4f46e5; text-decoration: none; }\n" +
|
||||
" @media (max-width: 560px) { .content { padding: 28px 20px; } .code { font-size: 36px; letter-spacing: 4px; padding: 8px 16px; } .header h1 { font-size: 24px; } }\n" +
|
||||
" </style>\n" +
|
||||
"</head>\n" +
|
||||
"<body>\n" +
|
||||
" <div class=\"container\">\n" +
|
||||
" <div class=\"header\">\n" +
|
||||
" <h1>Вход в аккаунт</h1>\n" +
|
||||
" </div>\n" +
|
||||
" <div class=\"content\">\n" +
|
||||
" <div class=\"greeting\">Здравствуйте, " + userName + "!</div>\n" +
|
||||
" <div class=\"message\">\n" +
|
||||
" Мы получили запрос на вход в ваш аккаунт. Используйте код подтверждения ниже, чтобы завершить вход.\n" +
|
||||
" </div>\n" +
|
||||
" <div class=\"code-box\">\n" +
|
||||
" <div class=\"code-label\">Ваш одноразовый код</div>\n" +
|
||||
" <div class=\"code\">" + code + "</div>\n" +
|
||||
" <div class=\"expiry\">Действителен 10 минут</div>\n" +
|
||||
" </div>\n" +
|
||||
" <div style=\"text-align: center; margin-top: 8px;\">\n" +
|
||||
" <a href=\"#\" class=\"button\">Перейти на страницу входа</a>\n" +
|
||||
" </div>\n" +
|
||||
" <div class=\"message\" style=\"font-size: 14px; margin-top: 24px; color: #64748b;\">\n" +
|
||||
" Если вы не запрашивали вход, просто проигнорируйте это письмо. Никому не сообщайте код.\n" +
|
||||
" </div>\n" +
|
||||
" </div>\n" +
|
||||
" <div class=\"footer\">\n" +
|
||||
" (c) 2025 Ваша компания | <a href=\"#\">Помощь</a> | <a href=\"#\">Безопасность</a>\n" +
|
||||
" </div>\n" +
|
||||
" </div>\n" +
|
||||
"</body>\n" +
|
||||
"</html>";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user