R.I.P me........

This commit is contained in:
2026-05-06 21:15:12 +03:00
parent a406af54bd
commit 0e6103f138
8 changed files with 1193 additions and 33 deletions

View File

@@ -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"

View File

@@ -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,

View 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>

View File

@@ -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;

View File

@@ -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);

View 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);
}
}

View File

@@ -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());
}
}

View 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>";
}
}