Compare commits

..

48 Commits

Author SHA1 Message Date
danil-bodry 16fc97ab6d fix 2026-05-14 18:29:35 +03:00
danil-bodry 98e3c95294 fix 2026-05-14 15:27:40 +03:00
danil-bodry 3d9fc71e7d up & fix 2026-05-14 13:19:04 +03:00
danil-bodry 98f854c1a3 fix 2026-05-09 14:33:00 +03:00
danil-bodry f3a0750891 fix 2026-05-09 14:32:25 +03:00
danil-bodry f43314b122 fix 2026-05-09 14:06:35 +03:00
danil-bodry 57b5a9fa12 fix 2026-05-09 14:06:21 +03:00
danil-bodry 1e7587e11b feat: implement version display with commit hash and date 2026-05-09 14:05:25 +03:00
danil-bodry debf1b165f fix: update sql code & download prettily JSON 2026-05-09 13:08:35 +03:00
danil-bodry f3b407e1ed add: translation of OlapConstructor.vue 2026-05-09 13:02:40 +03:00
danil-bodry 5382488a82 fix: economical JSON 2026-05-08 15:22:25 +03:00
danil-bodry 031757353d add: translation of OlapQueries.vue
fix: optimization of translation
2026-05-08 15:17:17 +03:00
danil-bodry 8f86dc5831 fix: micro fixes 2026-05-08 14:03:28 +03:00
danil-bodry 1531215b43 fix: we no longer use sql-client-templates 2026-05-08 13:55:46 +03:00
danil-bodry fa0b2518af updated dependencies 2026-05-07 23:16:10 +03:00
danil-bodry 4b4486e3ef fix 2026-05-07 19:19:48 +03:00
danil-bodry 651d2a5d0c fix 2026-05-07 18:42:25 +03:00
danil-bodry a1bd5a2b5f ref 2026-05-07 18:33:51 +03:00
danil-bodry 71cae60b90 up 2026-05-07 18:31:53 +03:00
danil-bodry 0b20b77690 fix 2026-05-07 18:17:00 +03:00
danil-bodry 1a5b10b129 up 2026-05-07 18:05:17 +03:00
danil-bodry 59e283945c up 2026-05-07 17:36:03 +03:00
danil-bodry 096fb1a3e2 up... 2026-05-07 17:01:02 +03:00
danil-bodry c108ad4a5a up OLAPConstructor.vue 2026-05-07 15:45:47 +03:00
danil-bodry 4e60a78fbd up build.gradle.kts 2026-05-07 02:22:42 +03:00
danil-bodry 08368afbc5 fix 2026-05-07 01:35:07 +03:00
danil-bodry a851df0494 fix 2026-05-07 01:28:08 +03:00
danil-bodry b203d6b7d8 fix 2026-05-07 00:30:53 +03:00
danil-bodry 7ae323a0c5 fix 2026-05-07 00:02:00 +03:00
danil-bodry 8386be18ba fix 2026-05-07 00:01:28 +03:00
danil-bodry ec6c52c79d fix 2026-05-06 23:36:57 +03:00
danil-bodry 0e6103f138 R.I.P me........ 2026-05-06 21:15:12 +03:00
danil-bodry a406af54bd fix 2026-05-04 15:10:26 +03:00
danil-bodry 1ca4c90b88 up 2026-05-04 15:08:03 +03:00
danil-bodry a61c527ef9 up 2026-05-04 13:22:25 +03:00
danil-bodry f39d9ff11e up 2026-05-01 19:11:08 +03:00
danil-bodry c801783779 up, add OLAP columns page 2026-05-01 19:04:18 +03:00
danil-bodry 50d4ea10c6 fix 2026-04-29 01:10:35 +03:00
danil-bodry 0836f8e9e9 up 2026-04-29 01:03:11 +03:00
danil-bodry e7f135e8c1 up 2026-04-28 19:26:24 +03:00
danil-bodry 664092f415 fix 2026-04-28 15:07:14 +03:00
danil-bodry 38cc75a688 add Rate Limiter & fix 2026-04-28 15:00:21 +03:00
danil-bodry 7a60bb15fe fix 2026-04-27 16:11:54 +03:00
danil-bodry 43b57bdb0f up package.json 2026-04-27 15:48:22 +03:00
danil-bodry 05076eb367 fix and refactor code 2026-04-27 15:45:06 +03:00
danil-bodry 316d06b1d2 updated dependencies 2026-04-27 14:29:59 +03:00
danil-bodry a68f02bab4 updated dependencies 2026-04-27 14:24:47 +03:00
danil-bodry aad6ba3747 updated dependencies 2026-04-27 14:20:56 +03:00
115 changed files with 5292 additions and 834 deletions
+2
View File
@@ -185,3 +185,5 @@ nbdist/
!.vscode/extensions.json
/build/
/logs/
/src/main/resources/version.properties
/src/main/resources/webroot/
+3
View File
@@ -1,2 +1,5 @@
# iiko-connector
* `Числовые``Агрегация`
* `Категории``Группировка {ROW / COLUMN}`
* `Фильтры``Фильтрация`
+109 -13
View File
@@ -1,15 +1,19 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.gradle.api.tasks.testing.logging.TestLogEvent.*
import java.nio.file.Files
import java.nio.file.Paths
import java.time.Instant
import java.time.format.DateTimeFormatter
plugins {
java
application
id("com.gradleup.shadow") version "9.2.2"
id("com.gradleup.shadow") version "9.4.1"
id("com.github.node-gradle.node") version "7.1.0"
}
node {
version.set("22.19.0") // версия Node.js
version.set("24.15.0") // версия Node.js
npmVersion.set("11.12.1") // версия npm
download.set(true) // автоматически скачать Node.js
workDir.set(file("${project.projectDir}/.gradle/nodejs"))
@@ -18,13 +22,13 @@ node {
}
group = "com.example"
version = "1.0.0-SNAPSHOT"
version = "1.0.1-beta"
repositories {
mavenCentral()
}
val vertxVersion = "5.0.10"
val vertxVersion = "5.0.12"
val mainVerticleName = "su.xserver.iikocon.MainVerticle"
val launcherClassName = "io.vertx.launcher.application.VertxApplication"
@@ -38,7 +42,6 @@ dependencies {
implementation("io.vertx:vertx-launcher-application")
implementation("io.vertx:vertx-web-client")
implementation("io.vertx:vertx-config")
implementation("io.vertx:vertx-sql-client-templates")
implementation("io.vertx:vertx-health-check")
implementation("io.vertx:vertx-web")
implementation("io.vertx:vertx-mysql-client")
@@ -48,16 +51,28 @@ dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind")
// https://mvnrepository.com/artifact/org.mindrot/jbcrypt
// Source: https://mvnrepository.com/artifact/org.mindrot/jbcrypt
implementation("org.mindrot:jbcrypt:0.4")
// https://mvnrepository.com/artifact/org.slf4j/slf4j-api
// Source: https://mvnrepository.com/artifact/org.slf4j/slf4j-api
implementation("org.slf4j:slf4j-api:2.0.17")
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j2-impl
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.25.3")
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
implementation("org.apache.logging.log4j:log4j-core:2.25.3")
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api
implementation("org.apache.logging.log4j:log4j-api:2.25.3")
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j2-impl
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.25.4")
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
implementation("org.apache.logging.log4j:log4j-core:2.25.4")
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api
implementation("org.apache.logging.log4j:log4j-api:2.25.4")
implementation("io.vertx:vertx-jdbc-client")
// Source: https://mvnrepository.com/artifact/com.clickhouse/clickhouse-jdbc
implementation("com.clickhouse:clickhouse-jdbc:0.9.8")
// Source: https://mvnrepository.com/artifact/com.mysql/mysql-connector-j
implementation("com.mysql:mysql-connector-j:9.7.0")
// Source: https://mvnrepository.com/artifact/org.postgresql/postgresql
implementation("org.postgresql:postgresql:42.7.11")
// Source: https://mvnrepository.com/artifact/it.sauronsoftware.cron4j/cron4j
implementation("it.sauronsoftware.cron4j:cron4j:2.2.5")
}
@@ -132,3 +147,84 @@ tasks.register("collectAllDependencies") {
}
}
tasks.register("countCodeLines") {
group = "project"
description = "Подсчитывает количество строк кода"
doLast {
val extensions = listOf("java", "kts", "xml", "json", "yaml", "properties", "html", "css", "js", "ts", "vue", "sql")
val excludeDirs = listOf("build", "out", "gradle", ".idea", "dist", "package-lock")
val counts = mutableMapOf<String, Int>()
fileTree(".").forEach { file ->
val ext = file.extension
if (ext in extensions && excludeDirs.none { file.path.contains(it) }) {
try {
val lines = Files.readAllLines(Paths.get(file.path))
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("//") && !it.startsWith("#") && !it.startsWith("*") && !it.startsWith("<!--") }
.size
counts[ext] = counts.getOrDefault(ext, 0) + lines
} catch (_: Exception) {
}
}
}
val total = counts.values.sum()
val report = buildString {
appendLine("Подсчёт строк кода:")
appendLine("=".repeat(55))
counts.toSortedMap().forEach { (ext, lines) ->
val percent = (lines * 100.0 / total).let { "%.2f".format(it) }
appendLine(" - ${ext.padEnd(10)} : ${lines.toString().padStart(6)} строк (${percent}%)")
}
appendLine("=".repeat(55))
appendLine("Всего строк кода: $total")
}
println(report)
val reportFile = file("build/reports/lineCount.txt")
reportFile.parentFile.mkdirs()
reportFile.writeText(report)
println("\nОтчёт сохранён в: ${reportFile.absolutePath}")
}
}
tasks.register("generateVersionFile") {
doLast {
// Версия из gradle.properties (по умолчанию 'unspecified', если не задана)
val version = project.version.takeIf { it.toString() != "unspecified" }?.toString() ?: "0.0.0"
// Получение короткого хэша коммита (с обработкой ошибки, если git не доступен)
val commitHash = try {
providers.exec {
commandLine("git", "rev-parse", "--short", "HEAD")
}.standardOutput.asText.get().trim()
} catch (e: Exception) {
logger.warn("Не удалось получить хэш коммита: ${e.message}")
"unknown"
}
val buildTime = DateTimeFormatter.ISO_INSTANT.format(Instant.now())
val propertiesContent = """
version=$version
commit.hash=$commitHash
build.time=$buildTime
""".trimIndent()
val resourceDir = file("src/main/resources")
resourceDir.mkdirs()
file("$resourceDir/version.properties").writeText(propertiesContent)
logger.lifecycle("✅ Файл version.properties создан: версия=$version, коммит=$commitHash")
}
}
tasks.processResources {
dependsOn("generateVersionFile")
}
+8 -2
View File
@@ -34,11 +34,13 @@ services:
environment:
PMA_HOST: iiko-db
PMA_PORT: 3306
PMA_USER: root
PMA_PASSWORD: DVjXT_kew508
UPLOAD_LIMIT: 10M
PMA_ABSOLUTE_URI: https://iiko-app.dev.xserver.su/phpmyadmin/
TZ: Europe/Moscow
ports:
- "7102:80"
# ports:
# - "7102:80"
iiko-redis:
image: redis:latest
@@ -75,5 +77,9 @@ services:
REDIS__HOST: iiko-redis
REDIS__PORT: 6379
SERVER__PORT: 7104
PMA__ENABLED: true
PMA__BASE_PATH: /phpmyadmin
PMA__UPSTREAM: http://iiko-pma:80/
volumes:
- $PWD/app/logs:/app/logs
-8
View File
@@ -1,8 +0,0 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
+2 -2
View File
@@ -13,7 +13,7 @@
"axios": "^1.15.0",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-i18n": "^9.14.5",
"vue-i18n": "^11.4.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
@@ -23,7 +23,7 @@
"postcss": "^8.5.9",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vite": "^7.3.2",
"vite-plugin-vue-devtools": "^8.1.1"
},
"engines": {
+7 -2
View File
@@ -21,10 +21,12 @@
</style>
<script setup lang="ts">
import { watch } from 'vue'
import { useSettingsStore } from './stores/settings'
import { watch, onMounted } from 'vue'
import { useSettingsStore } from '@/stores/settings'
import { useVersionStore } from '@/stores/version'
const settings = useSettingsStore()
const versionStore = useVersionStore()
watch(() => settings.siteDescription, (desc) => {
let meta = document.querySelector('meta[name="description"]')
@@ -35,4 +37,7 @@ watch(() => settings.siteDescription, (desc) => {
}
meta.setAttribute('content', desc || '')
}, { immediate: true })
onMounted(() => {
versionStore.fetchVersion()
})
</script>
+88 -34
View File
@@ -34,11 +34,12 @@
to="/dashboard"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
$route.path === '/dashboard' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
route.path === '/dashboard' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.dashboard') : ''"
>
<!-- Dashboard icon -->
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
@@ -50,11 +51,12 @@
to="/users"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
$route.path === '/users' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
route.path === '/users' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.users') : ''"
>
<!-- Users icon (multiple persons) -->
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
@@ -65,57 +67,86 @@
to="/restaurants"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
$route.path === '/restaurants' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
route.path === '/restaurants' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.restaurants') : ''"
>
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
<!-- Restaurant icon (fork & knife) -->
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
</router-link>
<router-link
v-if="userStore.role === 'admin'"
to="/olap/columns"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
route.path === '/olap/columns' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('olapColumns.title') : ''"
>
<!-- OLAP Columns icon (grid / columns) -->
<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">{{ t('olapColumns.title') }}</span>
</router-link>
<router-link
to="/olap/queries"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
route.path === '/olap/queries' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('olapQueries.title') : ''"
>
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h8M8 14h5" />
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('olapQueries.title') }}</span>
</router-link>
<router-link
v-if="userStore.role === 'admin'"
to="/database-connections"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
route.path === '/database-connections' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('dbConnections.pageName') : ''"
>
<!-- Database icon (cylinder) -->
<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 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('dbConnections.pageName') }}</span>
</router-link>
<router-link
v-if="userStore.role === 'admin'"
to="/settings"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
$route.path === '/settings' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
route.path === '/settings' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.settings') : ''"
>
<!-- Settings icon (gear) -->
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
</router-link>
<!-- PhpMyAdmin - только для администраторов -->
<a
v-if="userStore.role === 'admin'"
href="/phpmyadmin"
target="_self"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors no-router-link"
:class="[
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3',
$route.path === '/phpmyadmin' ? 'bg-primary-50 text-primary-700' : 'text-gray-700'
]"
:title="sidebarCollapsed ? t('app.database') : ''"
>
<!-- Иконка базы данных -->
<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 7v10c0 2 1.5 3 3 3h10c1.5 0 3-1 3-3V7c0-2-1.5-3-3-3H7c-1.5 0-3 1-3 3z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 7c0 2 1.5 3 3 3h10c1.5 0 3-1 3-3" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 12c0 2 1.5 3 3 3h10c1.5 0 3-1 3-3" />
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.database') }}</span>
</a>
</nav>
<!-- User Info (collapsed aware) -->
@@ -135,6 +166,16 @@
{{ userInitials }}
</div>
</div>
<!-- Версия сборки (всегда внизу) -->
<div v-if="!sidebarCollapsed" class="px-4 py-3 border-t border-gray-200 text-xs text-gray-500 text-center" :title="versionStore.getFormattedVersion(t)">
{{ versionStore.getShortVersion() }}
</div>
<div v-else class="p-2 border-t border-gray-200 flex justify-center">
<div class="text-xs text-gray-500 font-mono" :title="versionStore.getFormattedVersion(t)">
{{ versionStore.version?.commitHash?.slice(0, 6) }}
</div>
</div>
</div>
</aside>
@@ -176,6 +217,17 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</router-link>
<a
href="/phpmyadmin/"
v-if="userStore.role === 'admin'"
target="_self"
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
:title="t('app.database')"
>
<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 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</a>
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.logout')">
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
@@ -209,13 +261,15 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSettingsStore } from '../../stores/settings'
import { useUserStore } from '../../stores/user'
import { useSettingsStore } from '@/stores/settings'
import { useUserStore } from '@/stores/user'
import { useI18n } from 'vue-i18n'
import { useNotification } from '../../composables/useNotification'
import { useNotification } from '@/composables/useNotification'
import { useVersionStore } from '@/stores/version'
const { notification } = useNotification()
const { notification, showNotification } = useNotification()
const settings = useSettingsStore()
const versionStore = useVersionStore()
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
+177 -6
View File
@@ -13,15 +13,15 @@
"notifications": "Notifications",
"administrator": "Administrator",
"user": "User",
"yes": "Yes",
"no": "No",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"reset": "Reset",
"loading": "Loading..."
"loading": "Loading...",
"all": "all",
"confirm": "Confirm"
},
"common": {
"id": "ID",
@@ -46,11 +46,13 @@
"deleteConfirmation": "Are you sure you want to delete this item? This action cannot be undone.",
"operationSuccess": "Operation completed successfully",
"operationFailed": "Operation failed",
"networkError": "Network error"
"networkError": "Network error",
"version": "Version",
"versionFrom": "from"
},
"dashboard": {
"totalUsers": "Total Users",
"activeSessions": "Active Sessions",
"totalRestaurants": "Total Restaurants",
"systemHealth": "System Health",
"uptime": "Uptime",
"vsLastMonth": "vs last month",
@@ -135,7 +137,6 @@
"email": "Email Address",
"newPassword": "New Password",
"confirmPassword": "Confirm New Password",
"language": "Language",
"save": "Save Changes",
"reset": "Reset",
"role": "Role",
@@ -193,5 +194,175 @@
"minLength": "Must be at least {min} characters",
"email": "Please enter a valid email address",
"passwordMismatch": "Passwords do not match"
},
"olapColumns": {
"title": "OLAP Reports Structure",
"initialize": "Initialize",
"filterFieldKey": "Field key",
"filterFieldKeyPlaceholder": "search by key...",
"filterReportType": "Report type",
"filterTag": "Tag",
"fieldKey": "Field (key)",
"reportTypes": "Report types",
"type": "Type",
"tags": "Tags",
"aggregation": "Aggregation",
"grouping": "Grouping",
"filtering": "Filtering",
"noColumnsFound": "No fields match the filters",
"selectRestaurant": "Select a restaurant to load the structure",
"loadError": "Error loading report structure",
"initSuccess": "Structure initialized successfully",
"initError": "Initialization error: {error}",
"selectRestaurantFirst": "Please select a restaurant",
"refreshStructure": "Refresh structure",
"refreshWarningTitle": "Full structure replacement",
"refreshWarningMessage": "You selected restaurant «{restaurant}». All existing OLAP fields data will be permanently deleted and replaced with data from this restaurant.",
"refreshWarningConfirm": "This action is irreversible. Continue?",
"searchRestaurant": "Search restaurant...",
"noRestaurantsFound": "No restaurants found",
"initializingData": "Initializing OLAP fields structure",
"refreshingData": "Refreshing OLAP fields structure",
"waitMessage": "Please wait. This operation may take a while...",
"editField": "Edit Field",
"displayType": "Display Type",
"updateSuccess": "Field updated successfully",
"updateError": "Error updating field",
"deleteSuccess": "Field deleted successfully",
"deleteError": "Error deleting field",
"deleteField": "Delete Field",
"deleteFieldConfirm": "Are you sure you want to delete this field? This action cannot be undone."
},
"olapQueries": {
"title": "OLAP Queries",
"createButton": "Create Query",
"lastRun": "Last Run",
"result": "Result",
"connection": "Connection",
"success": "Success",
"error": "Error",
"noQueries": "No queries. Create your first!",
"deleteQueriesTitle": "Delete query?",
"deleteQueriesMessage": "This action is irreversible. Are you sure?",
"loadError": "Failed to load queries",
"deleteSuccess": "Query deleted",
"deleteError": "Delete error"
},
"OlapConstructor": {
"titleNew": "New OLAP Query",
"titleEdit": "Edit Query",
"saveQuery": "Save Query",
"exportJson": "Export JSON",
"importJson": "Import JSON",
"fieldsTitle": "Fields",
"searchPlaceholder": "Search by name or tags",
"foundCount": "Found: {found} / {total}",
"loadingFields": "Loading fields...",
"noFields": "No fields. Initialize the structure in the OLAP Columns section first.",
"numericGroup": "Numeric → VALUES",
"categoryGroup": "Categories → ROW / COLUMN",
"filtersGroup": "Filters",
"resetAll": "Reset All",
"queryNameLabel": "Query name *",
"queryNamePlaceholder": "For example: Sales by day",
"valuePlaceholder": "value",
"IncludeValuesPlaceholder": "value1,value2",
"reportTypeLabel": "Report type",
"sqlTableLabel": "SQL table *",
"dateToLabel": "Date to (end of day)",
"daysBackLabel": "Days back (≥1)",
"summaryCheckbox": "Summary",
"activeCheckbox": "Active",
"restaurantsLabel": "Restaurants (multiple) *",
"selectRestaurants": "Select restaurants",
"selectedCount": "Selected: {count}",
"dbConnectionLabel": "Database connection *",
"selectDbConnection": "Select connection",
"tabTable": "Table",
"tabSql": "SQL script",
"copySql": "Copy SQL",
"defaultSqlPlaceholder": "-- Select a database connection to generate SQL",
"userFiltersTitle": "Custom filters",
"dropFilterHint": "Drop a filter field",
"rowHeader": "ROW",
"columnHeader": "COLUMN",
"valuesHeader": "VALUES",
"dropCategoryHint": "Drop a category",
"dropNumberHint": "Drop a number",
"resetModalTitle": "Reset All Settings",
"resetModalMessage": "Are you sure? All selected fields, filters, and settings will be deleted.",
"restaurantModalTitle": "Select Restaurants",
"restaurantSearchPlaceholder": "Search by name or host",
"noRestaurantsFound": "No restaurants found",
"dbModalTitle": "Database Connection",
"dbSearchPlaceholder": "Search by name",
"noConnectionsFound": "No connections found",
"exitModalTitle": "Unsaved Changes",
"exitModalMessage": "Are you sure you want to exit? All unsaved data will be lost.",
"exitModalStay": "Stay",
"exitModalLeave": "Leave",
"reportTypes": {
"SALES": "SALES",
"DELIVERIES": "DELIVERIES",
"TRANSACTIONS": "TRANSACTIONS"
},
"aggregations": {
"sum": "SUM",
"avg": "AVG",
"count": "COUNT"
},
"filterTypes": {
"IncludeValues": "Include Values",
"ExcludeValues": "Exclude Values",
"EnumValue": "Enum Value",
"StringValue": "String Value"
},
"notifications": {
"errorLoadRestaurants": "Couldn't load restaurant list",
"errorLoadDB": "The list of database connections could not be loaded",
"sqlGenerationError": "-- SQL generation error: {error}",
"exportSuccess": "iiko configuration exported",
"exportError": "Export error: {error}",
"importSuccess": "iiko configuration loaded",
"importError": "Error loading JSON: {error}",
"queryNameRequired": "Enter query name",
"dbConnectionRequired": "Select a database connection",
"restaurantsRequired": "Select at least one restaurant",
"tableNameRequired": "Specify SQL table name",
"saveSuccess": "Query saved",
"saveError": "Save error: {error}",
"loadQueryError": "Error loading query: {error}",
"resetSuccess": "All settings reset",
"sqlCopied": "SQL script copied",
"restaurantsSelected": "Restaurants selected",
"dbConnectionSelected": "Database connection selected",
"loadColumnsError": "Error loading fields: {error}"
}
},
"dbConnections": {
"pageName": "Databases",
"add": "Add Connection",
"edit": "Edit Connection",
"delete": "Delete Connection",
"deleteConfirmation": "Are you sure you want to delete this database connection? This action cannot be undone.",
"type": "Type",
"host": "Host",
"port": "Port",
"database": "Database",
"user": "User",
"test": "Test connection",
"noConnections": "No database connections found. Click 'Add Connection' to create one.",
"loadError": "Failed to load database connections.",
"testSuccess": "Connection successful! Latency: {latency} ms",
"testError": "Connection failed: {error}",
"testNetworkError": "Network error while testing connection: {error}",
"testUnknownError": "Unknown error",
"passwordRequired": "Password is required for new connection.",
"createSuccess": "Database connection created successfully.",
"updateSuccess": "Database connection updated successfully.",
"createError": "Failed to create database connection.",
"updateError": "Failed to update database connection.",
"deleteSuccess": "Database connection deleted successfully.",
"deleteError": "Failed to delete database connection."
}
}
+178 -7
View File
@@ -2,7 +2,7 @@
"app": {
"title": "Панель администратора",
"dashboard": "Панель управления",
"database": "База Данных",
"database": "База данных",
"users": "Пользователи",
"restaurants": "Рестораны",
"settings": "Настройки",
@@ -13,15 +13,15 @@
"notifications": "Уведомления",
"administrator": "Администратор",
"user": "Пользователь",
"yes": "Да",
"no": "Нет",
"cancel": "Отмена",
"save": "Сохранить",
"delete": "Удалить",
"edit": "Редактировать",
"add": "Добавить",
"reset": "Сбросить",
"loading": "Загрузка..."
"loading": "Загрузка...",
"all": "все",
"confirm": "Подтвердить"
},
"common": {
"id": "ID",
@@ -46,11 +46,13 @@
"deleteConfirmation": "Вы уверены, что хотите удалить этот элемент? Это действие необратимо.",
"operationSuccess": "Операция выполнена успешно",
"operationFailed": "Операция не удалась",
"networkError": "Ошибка сети"
"networkError": "Ошибка сети",
"version": "Версия",
"versionFrom": "от"
},
"dashboard": {
"totalUsers": "Всего пользователей",
"activeSessions": "Активных сессий",
"totalRestaurants": "Всего ресторанов",
"systemHealth": "Здоровье системы",
"uptime": "Время работы",
"vsLastMonth": "по сравнению с прошлым месяцем",
@@ -135,7 +137,6 @@
"email": "Email",
"newPassword": "Новый пароль",
"confirmPassword": "Подтверждение нового пароля",
"language": "Язык",
"save": "Сохранить изменения",
"reset": "Сбросить",
"role": "Роль",
@@ -193,5 +194,175 @@
"minLength": "Должно быть не менее {min} символов",
"email": "Введите корректный email адрес",
"passwordMismatch": "Пароли не совпадают"
},
"olapColumns": {
"title": "OLAP поля",
"initialize": "Инициализировать",
"filterFieldKey": "Ключ поля",
"filterFieldKeyPlaceholder": "поиск по ключу...",
"filterReportType": "Тип отчёта",
"filterTag": "Тег",
"fieldKey": "Поле (ключ)",
"reportTypes": "Типы отчётов",
"type": "Тип",
"tags": "Теги",
"aggregation": "Агрегация",
"grouping": "Группировка",
"filtering": "Фильтрация",
"noColumnsFound": "Нет полей, соответствующих фильтрам",
"selectRestaurant": "Выберите ресторан для загрузки структуры",
"loadError": "Ошибка загрузки структуры отчётов",
"initSuccess": "Структура успешно инициализирована",
"initError": "Ошибка инициализации: {error}",
"selectRestaurantFirst": "Пожалуйста, выберите ресторан",
"refreshStructure": "Обновить структуру",
"refreshWarningTitle": "Полная замена структуры",
"refreshWarningMessage": "Вы выбрали ресторан «{restaurant}». Все текущие данные о полях OLAP-отчётов будут полностью удалены и заменены данными из этого ресторана.",
"refreshWarningConfirm": "Это действие необратимо. Продолжить?",
"searchRestaurant": "Поиск ресторана...",
"noRestaurantsFound": "Рестораны не найдены",
"initializingData": "Инициализация структуры OLAP-полей",
"refreshingData": "Обновление структуры OLAP-полей",
"waitMessage": "Пожалуйста, подождите. Операция может занять некоторое время...",
"editField": "Редактирование поля",
"displayType": "Тип отображения",
"updateSuccess": "Поле успешно обновлено",
"updateError": "Ошибка при обновлении поля",
"deleteSuccess": "Поле успешно удалено",
"deleteError": "Ошибка при удалении поля",
"deleteField": "Удаление поля",
"deleteFieldConfirm": "Вы уверены, что хотите удалить это поле? Это действие необратимо."
},
"olapQueries": {
"title": "OLAP запросы",
"createButton": "Создать запрос",
"lastRun": "Последнее выполнение",
"result": "Результат",
"connection": "Подключение",
"success": "Успешно",
"error": "Ошибка",
"noQueries": "Нет запросов. Создайте первый!",
"deleteQueriesTitle": "Удалить запрос?",
"deleteQueriesMessage": "Действие необратимо. Вы уверены?",
"loadError": "Ошибка загрузки запросов",
"deleteSuccess": "Запрос удалён",
"deleteError": "Ошибка удаления"
},
"OlapConstructor": {
"titleNew": "Новый OLAP запрос",
"titleEdit": "Редактирование запроса",
"saveQuery": "Сохранить запрос",
"exportJson": "Экспорт JSON",
"importJson": "Импорт JSON",
"fieldsTitle": "Поля",
"searchPlaceholder": "Поиск по названию или тегам",
"foundCount": "Найдено: {found} / {total}",
"loadingFields": "Загрузка полей...",
"noFields": "Нет полей. Сначала инициализируйте структуру в разделе OLAP Columns.",
"numericGroup": "Числовые → VALUES",
"categoryGroup": "Категории → ROW / COLUMN",
"filtersGroup": "Фильтры",
"resetAll": "Сбросить всё",
"queryNameLabel": "Имя запроса *",
"queryNamePlaceholder": "Например: Продажи по дням",
"valuePlaceholder": "значение",
"IncludeValuesPlaceholder": "знач1,знач2",
"reportTypeLabel": "Тип отчета",
"sqlTableLabel": "Таблица SQL *",
"dateToLabel": "Дата до (конец дня)",
"daysBackLabel": "Дней назад (≥1)",
"summaryCheckbox": "Summary",
"activeCheckbox": "Активно",
"restaurantsLabel": "Рестораны (можно несколько) *",
"selectRestaurants": "Выбрать рестораны",
"selectedCount": "Выбрано: {count}",
"dbConnectionLabel": "Подключение к БД *",
"selectDbConnection": "Выбрать подключение",
"tabTable": "Таблица",
"tabSql": "SQL скрипт",
"copySql": "Копировать SQL",
"defaultSqlPlaceholder": "-- Выберите подключение к БД для генерации SQL",
"userFiltersTitle": "Пользовательские фильтры",
"dropFilterHint": "Перетащите поле фильтра",
"rowHeader": "ROW",
"columnHeader": "COLUMN",
"valuesHeader": "VALUES",
"dropCategoryHint": "Перетащите категорию",
"dropNumberHint": "Перетащите число",
"resetModalTitle": "Сброс всех настроек",
"resetModalMessage": "Вы уверены? Все выбранные поля, фильтры, настройки будут удалены.",
"restaurantModalTitle": "Выбор ресторанов",
"restaurantSearchPlaceholder": "Поиск по названию или хосту",
"noRestaurantsFound": "Рестораны не найдены",
"dbModalTitle": "Подключение к БД",
"dbSearchPlaceholder": "Поиск по имени",
"noConnectionsFound": "Подключения не найдены",
"exitModalTitle": "Несохранённые изменения",
"exitModalMessage": "Вы уверены, что хотите выйти? Все несохранённые данные будут потеряны.",
"exitModalStay": "Остаться",
"exitModalLeave": "Выйти",
"reportTypes": {
"SALES": "ПРОДАЖИ",
"DELIVERIES": "ДОСТАВКИ",
"TRANSACTIONS": "ТРАНЗАКЦИИ"
},
"aggregations": {
"sum": "СУММА",
"avg": "СРЕДНЕЕ",
"count": "КОЛИЧЕСТВО"
},
"filterTypes": {
"IncludeValues": "Включая значения",
"ExcludeValues": "Исключая значения",
"EnumValue": "Значение перечисления",
"StringValue": "Строковое значение"
},
"notifications": {
"errorLoadRestaurants": "Не удалось загрузить список ресторанов",
"errorLoadDB": "Не удалось загрузить список подключений к БД",
"sqlGenerationError": "-- Ошибка генерации SQL: {error}",
"exportSuccess": "Конфигурация iiko экспортирована",
"exportError": "Ошибка экспорта: {error}",
"importSuccess": "Конфигурация iiko загружена",
"importError": "Ошибка при загрузке JSON: {error}",
"queryNameRequired": "Введите имя запроса",
"dbConnectionRequired": "-- Выберите подключение к БД для генерации SQL",
"restaurantsRequired": "Выберите хотя бы один ресторан",
"tableNameRequired": "Укажите название таблицы SQL",
"saveSuccess": "Запрос сохранён",
"saveError": "Ошибка сохранения: {error}",
"loadQueryError": "Ошибка загрузки запроса: {error}",
"resetSuccess": "Все настройки сброшены",
"sqlCopied": "SQL скрипт скопирован",
"restaurantsSelected": "Рестораны выбраны",
"dbConnectionSelected": "Подключение к БД выбрано",
"loadColumnsError": "Ошибка загрузки полей: {error}"
}
},
"dbConnections": {
"pageName": "Базы данных",
"add": "Добавить подключение",
"edit": "Редактировать подключение",
"delete": "Удалить подключение",
"deleteConfirmation": "Вы уверены, что хотите удалить это подключение к базе данных? Действие необратимо.",
"type": "Тип",
"host": "Хост",
"port": "Порт",
"database": "База данных",
"user": "Пользователь",
"test": "Проверить подключение",
"noConnections": "Подключения к базам данных не найдены. Нажмите «Добавить подключение», чтобы создать.",
"loadError": "Не удалось загрузить список подключений.",
"testSuccess": "Подключение успешно! Задержка: {latency} мс",
"testError": "Ошибка подключения: {error}",
"testNetworkError": "Сетевая ошибка при проверке подключения: {error}",
"testUnknownError": "Неизвестная ошибка",
"passwordRequired": "Пароль обязателен для нового подключения.",
"createSuccess": "Подключение к БД успешно создано.",
"updateSuccess": "Подключение к БД успешно обновлено.",
"createError": "Не удалось создать подключение к БД.",
"updateError": "Не удалось обновить подключение к БД.",
"deleteSuccess": "Подключение к БД успешно удалено.",
"deleteError": "Не удалось удалить подключение к БД."
}
}
+3 -3
View File
@@ -3,12 +3,12 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
import '@/style.css'
import { useSettingsStore } from './stores/settings'
import { useUserStore } from './stores/user'
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import ru from './locales/ru.json'
import en from '@/locales/en.json'
import ru from '@/locales/ru.json'
// Функция определения языка браузера
function getBrowserLocale(): string {
+86 -21
View File
@@ -1,27 +1,92 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../stores/user'
import { useSettingsStore } from '../stores/settings'
import Login from '../views/auth/Login.vue'
import Setup from '../views/auth/Setup.vue'
import Register from '../views/auth/Register.vue'
import Dashboard from '../views/Dashboard.vue'
import Users from '../views/Users.vue'
import Restaurants from '../views/Restaurants.vue'
import AdminSettings from '../views/AdminSettings.vue'
import Profile from '../views/Profile.vue'
import NotFound from '../views/NotFound.vue'
import { useUserStore } from '@/stores/user'
import { useSettingsStore } from '@/stores/settings'
import Login from '@/views/auth/Login.vue'
import Setup from '@/views/auth/Setup.vue'
import Register from '@/views/auth/Register.vue'
import Dashboard from '@/views/Dashboard.vue'
import Users from '@/views/Users.vue'
import Restaurants from '@/views/Restaurants.vue'
import OlapColumns from '@/views/OlapColumns.vue'
import OlapQueries from '@/views/OlapQueries.vue'
import OlapConstructor from '@/views/OlapConstructor.vue'
import DbConnections from '@/views/DbConnections.vue'
import AdminSettings from '@/views/AdminSettings.vue'
import Profile from '@/views/Profile.vue'
import NotFound from '@/views/NotFound.vue'
const routes = [
{ path: '/login', component: Login, meta: { title: 'Login', requiresAuth: false } },
{ path: '/register', component: Register, meta: { title: 'Register', requiresAuth: false } },
{ path: '/setup', component: Setup, meta: { title: 'Setup', requiresAuth: false } },
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true, title: 'Dashboard' } },
{ path: '/users', component: Users, meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' } },
{ path: '/restaurants', component: Restaurants, meta: { requiresAuth: true, title: 'Restaurants' } },
{ path: '/settings', component: AdminSettings, meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' } },
{ path: '/profile', component: Profile, meta: { requiresAuth: true, title: 'Profile' } },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { title: 'Page Not Found', requiresAuth: false } }
{
path: '/login',
component: Login,
meta: { title: 'Login', requiresAuth: false }
},
{
path: '/register',
component: Register,
meta: { title: 'Register', requiresAuth: false }
},
{
path: '/setup',
component: Setup,
meta: { title: 'Setup', requiresAuth: false }
},
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
component: Dashboard,
meta: { requiresAuth: true, title: 'Dashboard' }
},
{
path: '/users',
component: Users,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' }
},
{
path: '/restaurants',
component: Restaurants,
meta: { requiresAuth: true, title: 'Restaurants' }
},
{
path: '/olap/columns',
component: OlapColumns,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Olap Columns' }
},
{
path: '/olap/queries',
component: OlapQueries,
meta: { requiresAuth: true, title: 'OLAP Queries' }
},
{
path: '/olap/constructor/:id?',
component: OlapConstructor,
meta: { requiresAuth: true, title: 'OLAP Constructor' }
},
{
path: '/database-connections',
component: DbConnections,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Database Connections' }
},
{
path: '/settings',
component: AdminSettings,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
},
{
path: '/profile',
component: Profile,
meta: { requiresAuth: true, title: 'Profile' }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
meta: { title: 'Page Not Found', requiresAuth: false }
}
]
const router = createRouter({ history: createWebHistory(), routes })
+2 -2
View File
@@ -10,7 +10,7 @@ export const useUserStore = defineStore('user', () => {
async function fetchProfile() {
try {
const res = await fetch('/api/admin/profile')
const res = await fetch('/api/profile')
if (res.ok) {
const data = await res.json()
id.value = data.id
@@ -27,7 +27,7 @@ export const useUserStore = defineStore('user', () => {
}
async function updateProfile(updates: { email?: string; password?: string; language?: string }) {
const res = await fetch('/api/admin/profile', {
const res = await fetch('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
+62
View File
@@ -0,0 +1,62 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface BuildVersion {
version: string
commitHash: string
buildTime: string // ISO строка, например "2025-04-03T12:34:56Z"
}
export const useVersionStore = defineStore('version', () => {
const version = ref<BuildVersion | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchVersion() {
if (version.value) return
loading.value = true
try {
const res = await fetch('/api/build-info')
if (!res.ok) throw new Error('Failed to fetch version')
const data = await res.json()
version.value = {
version: data.version || '0.0.0',
commitHash: data.commitHash || 'unknown',
buildTime: data.buildTime || ''
}
} catch (err: any) {
console.error(err)
error.value = err.message
version.value = { version: 'dev', commitHash: 'unknown', buildTime: '' }
} finally {
loading.value = false
}
}
// Отформатированная дата сборки (только дата, без времени)
const buildDateFormatted = computed(() => {
if (!version.value?.buildTime) return ''
const date = new Date(version.value.buildTime)
if (isNaN(date.getTime())) return ''
// Формат YYYY-MM-DD (универсальный, без локализации)
return date.toISOString().split('T')[0]
})
// Полная строка версии: "Версия: 1.2.3 (build abc1234 от 2025-04-03)"
// Принимает функцию перевода для слова "от"/"from"
const getFormattedVersion = (t: (key: string) => string) => {
if (!version.value) return t('common.version') + '...'
const { version: ver, commitHash } = version.value
const datePart = buildDateFormatted.value ? ` ${t('common.versionFrom')} ${buildDateFormatted.value}` : ''
return `${t('common.version')}: ${ver} (build ${commitHash}${datePart})`
}
const getShortVersion = () => {
if (!version.value) return '...'
const { version: ver, commitHash } = version.value
const datePart = buildDateFormatted.value ? ` ${buildDateFormatted.value}` : ''
return `${ver} (build ${commitHash}${datePart})`
}
return { version, loading, error, fetchVersion, buildDateFormatted, getFormattedVersion, getShortVersion }
})
+5 -19
View File
@@ -58,22 +58,18 @@
<button type="submit" class="btn-primary">{{ t('settings.save') }}</button>
</div>
</form>
<div v-if="message" class="mt-4 p-3 rounded-lg" :class="messageClass">
{{ message }}
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n'
import { useNotification } from '../composables/useNotification'
import { useNotification } from '@/composables/useNotification'
const { showNotification } = useNotification()
const { t, locale } = useI18n()
const { t } = useI18n()
interface FieldMeta {
key: string;
label: string;
@@ -86,11 +82,9 @@ interface FieldMeta {
const meta = ref<FieldMeta[]>([]);
const values = ref<Record<string, string>>({});
const message = ref('');
const messageClass = ref('');
async function loadMeta() {
const res = await fetch('/api/settings/meta');
const res = await fetch('/api/admin/settings/meta');
if (res.ok) {
meta.value = await res.json();
} else {
@@ -99,7 +93,7 @@ async function loadMeta() {
}
async function loadValues() {
const res = await fetch('/api/settings/all');
const res = await fetch('/api/admin/settings');
if (res.ok) {
values.value = await res.json();
} else {
@@ -128,13 +122,5 @@ async function saveSettings() {
}
}
function showMessage(text: string, cssClass: string) {
message.value = text;
messageClass.value = cssClass;
setTimeout(() => {
message.value = '';
}, 3000);
}
onMounted(loadData);
</script>
+8 -10
View File
@@ -23,8 +23,8 @@
<div class="card hover:shadow-md transition-shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.activeSessions') }}</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.activeSessions }}</p>
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.totalRestaurants') }}</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalRestaurants }}</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -175,13 +175,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
import { useNotification } from '../composables/useNotification'
import { useNotification } from '@/composables/useNotification'
const { showNotification } = useNotification()
const stats = ref({ totalUsers: 0, activeSessions: 0, systemHealth: 100, uptime: '99.9%' });
const stats = ref({ totalUsers: 0, totalRestaurants: 0, systemHealth: 100, uptime: '99.9%' });
const userGrowth = ref(12);
const sessionGrowth = ref(5);
const recentUsers = ref([]);
@@ -195,20 +195,18 @@ let interval: number;
async function loadDashboardData() {
try {
const [usersRes, sessionsRes, healthRes, restaurantsRes] = await Promise.all([
const [usersRes, healthRes, restaurantsRes] = await Promise.all([
fetch('/api/admin/users'),
fetch('/api/admin/active-sessions'),
fetch('/api/health'),
fetch('/api/admin/restaurants')
]);
const users = await usersRes.json();
const sessions = await sessionsRes.json();
const health = await healthRes.json();
const restaurants = await restaurantsRes.json();
stats.value.totalUsers = users.length;
stats.value.activeSessions = sessions.count || 0;
stats.value.totalRestaurants = restaurants.length;
recentUsers.value = users.slice(-5).reverse();
recentRestaurants.value = restaurants.slice(-5).reverse();
@@ -242,7 +240,7 @@ function formatDate(dateStr: string) {
onMounted(() => {
loadDashboardData();
interval = window.setInterval(loadDashboardData, 30000);
interval = window.setInterval(loadDashboardData, 10000);
});
onUnmounted(() => {
+384
View File
@@ -0,0 +1,384 @@
<template>
<AppLayout>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ t('dbConnections.pageName') }}</h1>
<button @click="openModal('create')" class="btn-primary 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="M12 4v16m8-8H4" />
</svg>
{{ t('dbConnections.add') }}
</button>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.type') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.host') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.port') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.database') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.user') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="conn in connections" :key="conn.id" class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ conn.id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ conn.name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span :class="getTypeBadgeClass(conn.type)" class="px-2 py-1 rounded-full text-xs font-medium">
{{ getTypeLabel(conn.type) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.host }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.port }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.database }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.user }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(conn.created) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-3">
<button @click="testConnection(conn)" :disabled="conn.testing" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50" :title="t('dbConnections.test')">
<svg v-if="!conn.testing" 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" 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>
</button>
<button @click="openModal('edit', conn)" class="text-blue-600 hover:text-blue-800 transition-colors">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="confirmDelete(conn.id)" class="text-red-600 hover:text-red-800 transition-colors">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<span v-if="conn.testResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
{{ conn.testResult }}
</span>
</div>
</td>
</tr>
<tr v-if="connections.length === 0">
<td colspan="9" class="px-6 py-12 text-center text-gray-500">{{ t('dbConnections.noConnections') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модальное окно создания/редактирования -->
<Transition name="fade">
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
<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 transform transition-all">
<div class="flex justify-between items-center p-6 border-b">
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="submitConnection" class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }} *</label>
<input v-model="form.name" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.type') }} *</label>
<select v-model="form.type" required class="input-field">
<option value="mysql">MySQL</option>
<option value="postgres">PostgreSQL</option>
<option value="clickhouse">ClickHouse</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.host') }} *</label>
<input v-model="form.host" type="text" required class="input-field" placeholder="localhost or IP" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.port') }} *</label>
<input v-model="form.port" type="number" required class="input-field" placeholder="3306, 5432, 8123..." />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.database') }} *</label>
<input v-model="form.database" type="text" required class="input-field" placeholder="database name" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.user') }} *</label>
<input v-model="form.user" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.password') }}</label>
<input
v-model="form.password"
:required="modalMode === 'create'"
type="password"
class="input-field"
autocomplete="new-password"
/>
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" @click="closeModal" class="btn-secondary">{{ t('app.cancel') }}</button>
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
</div>
</form>
</div>
</div>
</div>
</Transition>
<!-- Модальное окно подтверждения удаления -->
<Transition name="fade">
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.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">{{ t('dbConnections.delete') }}</h3>
<p class="text-sm text-gray-500 mb-6">{{ t('dbConnections.deleteConfirmation') }}</p>
<div class="flex justify-center space-x-3">
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="deleteConnection(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
type Connection = {
id: number;
name: string;
type: 'mysql' | 'postgres' | 'clickhouse';
host: string;
port: number;
database: string;
user: string;
created: string;
testing?: boolean;
testResult?: string | null;
};
const connections = ref<Connection[]>([]);
const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create');
const form = ref({
id: null as number | null,
name: '',
type: 'mysql' as 'mysql' | 'postgres' | 'clickhouse',
host: '',
port: 3306,
database: '',
user: '',
password: ''
});
const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null as number | null });
// Загрузка списка подключений
async function loadConnections() {
try {
const res = await fetch('/api/admin/database-connections');
if (!res.ok) throw new Error();
const data = await res.json();
connections.value = data.map((c: any) => ({
...c,
testing: false,
testResult: null
}));
} catch (e) {
showNotification('dbConnections.loadError', 'error');
}
}
function formatDate(dateStr: string) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
// Тестирование соединения
async function testConnection(conn: Connection) {
conn.testing = true;
conn.testResult = null;
try {
const response = await fetch(`/api/admin/database-connections/${conn.id}/test`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
conn.testResult = `${data.latency_ms} ms`;
showNotification('dbConnections.testSuccess', 'success', { latency: data.latency_ms });
} else {
const errorText = data.error || t('dbConnections.testUnknownError');
showNotification('dbConnections.testError', 'error', { error: errorText });
}
} catch (error: any) {
showNotification('dbConnections.testNetworkError', 'error', { error: error.message });
} finally {
conn.testing = false;
}
}
// Вспомогательные функции для отображения типа
function getTypeLabel(type: string) {
const labels: Record<string, string> = {
mysql: 'MySQL',
postgres: 'PostgreSQL',
clickhouse: 'ClickHouse'
};
return labels[type] || type;
}
function getTypeBadgeClass(type: string) {
const classes: Record<string, string> = {
mysql: 'bg-blue-100 text-blue-800',
postgres: 'bg-indigo-100 text-indigo-800',
clickhouse: 'bg-amber-100 text-amber-800'
};
return classes[type] || 'bg-gray-100 text-gray-800';
}
function openModal(mode: 'create' | 'edit', conn: Connection | null = null) {
modalMode.value = mode;
if (mode === 'create') {
form.value = {
id: null,
name: '',
type: 'mysql',
host: '',
port: 3306,
database: '',
user: '',
password: ''
};
modalTitle.value = t('dbConnections.add');
} else if (conn) {
form.value = {
id: conn.id,
name: conn.name,
type: conn.type,
host: conn.host,
port: conn.port,
database: conn.database,
user: conn.user,
password: ''
};
modalTitle.value = t('dbConnections.edit');
}
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
}
async function submitConnection() {
if (modalMode.value === 'create' && !form.value.password) {
showNotification('dbConnections.passwordRequired', 'error');
return;
}
try {
const payload: any = {
name: form.value.name,
type: form.value.type,
host: form.value.host,
port: form.value.port,
database: form.value.database,
user: form.value.user,
};
if (form.value.password) {
payload.password = form.value.password;
}
if (modalMode.value === 'create') {
const res = await fetch('/api/admin/database-connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error();
showNotification('dbConnections.createSuccess', 'success');
} else {
const res = await fetch(`/api/admin/database-connections/${form.value.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error();
showNotification('dbConnections.updateSuccess', 'success');
}
await loadConnections();
closeModal();
} catch (e) {
showNotification(modalMode.value === 'create' ? 'dbConnections.createError' : 'dbConnections.updateError', 'error');
}
}
function confirmDelete(id: number) {
deleteConfirm.value = { show: true, id };
}
async function deleteConnection(id: number) {
try {
const res = await fetch(`/api/admin/database-connections/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification('dbConnections.deleteSuccess', 'success');
await loadConnections();
} catch (e) {
showNotification('dbConnections.deleteError', 'error');
} finally {
deleteConfirm.value.show = false;
}
}
onMounted(loadConnections);
</script>
<style scoped>
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+534
View File
@@ -0,0 +1,534 @@
<template>
<AppLayout>
<div class="flex justify-between items-center mb-6 flex-wrap gap-4">
<h1 class="text-2xl font-bold text-gray-900">{{ t('olapColumns.title') }}</h1>
<div class="flex gap-3">
<button
v-if="hasData && !loading && !initializing"
@click="openRefreshModal"
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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('olapColumns.refreshStructure') }}
</button>
<button
v-if="!hasData && !loading && !initializing"
@click="openInitModal"
class="btn-primary 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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('olapColumns.initialize') }}
</button>
</div>
</div>
<!-- Фильтры -->
<div v-if="hasData" class="card mb-6 p-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olapColumns.filterFieldKey') }}</label>
<input v-model="filters.fieldKey" type="text" class="input-field" :placeholder="t('olapColumns.filterFieldKeyPlaceholder')" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olapColumns.filterReportType') }}</label>
<select v-model="filters.reportType" class="input-field">
<option value="">{{ t('app.all') }}</option>
<option v-for="rt in availableReportTypes" :key="rt" :value="rt">{{ rt }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olapColumns.filterTag') }}</label>
<select v-model="filters.tag" class="input-field">
<option value="">{{ t('app.all') }}</option>
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
</select>
</div>
<div class="flex items-end">
<button @click="resetFilters" class="btn-secondary w-full">{{ t('app.reset') }}</button>
</div>
</div>
</div>
<!-- Таблица -->
<div v-if="loading" class="card p-8 text-center">
<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">{{ t('app.loading') }}</p>
</div>
<div v-else-if="hasData && filteredColumns.length === 0" class="card p-8 text-center text-gray-500">
{{ t('olapColumns.noColumnsFound') }}
</div>
<div v-else-if="hasData" class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapColumns.fieldKey') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapColumns.reportTypes') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapColumns.type') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapColumns.tags') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapColumns.aggregation') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapColumns.grouping') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapColumns.filtering') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="col in filteredColumns" :key="col.fieldKey" class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm font-mono text-gray-900">{{ col.fieldKey }}</td>
<td class="px-6 py-4 text-sm text-gray-900">{{ col.name }}</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
<span v-for="rt in col.reportTypes" :key="rt" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
{{ rt }}
</span>
</div>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
{{ col.type }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
<span v-for="tag in col.tags" :key="tag" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
{{ tag }}
</span>
<span v-if="!col.tags || col.tags.length === 0" class="text-gray-400"></span>
</div>
</td>
<td class="px-6 py-4 text-center">
<span v-if="col.aggregationAllowed" class="text-green-600"></span>
<span v-else class="text-gray-300"></span>
</td>
<td class="px-6 py-4 text-center">
<span v-if="col.groupingAllowed" class="text-green-600"></span>
<span v-else class="text-gray-300"></span>
</td>
<td class="px-6 py-4 text-center">
<span v-if="col.filteringAllowed" class="text-green-600"></span>
<span v-else class="text-gray-300"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button @click="openDeleteFieldModal(col.fieldKey)" class="text-red-600 hover:text-red-800 transition-colors" :title="t('app.delete')">
<svg 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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модалка выбора ресторана (для инициализации и обновления) -->
<Transition name="fade">
<div v-if="initModalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeInitModal">
<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-lg w-full max-h-[90vh] flex flex-col">
<!-- Заголовок -->
<div class="flex justify-between items-center p-6 border-b">
<h3 class="text-xl font-bold text-gray-900">{{ initModalTitle }}</h3>
<button @click="closeInitModal" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Поле поиска -->
<div class="p-4 border-b">
<div class="relative">
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
v-model="restaurantSearch"
type="text"
class="input-field pl-9"
:placeholder="t('olapColumns.searchRestaurant')"
/>
</div>
</div>
<!-- Список ресторанов (скролл) -->
<div class="flex-1 overflow-y-auto p-2 space-y-2">
<div
v-for="rest in filteredRestaurants"
:key="rest.id"
@click="selectedRestaurantId = rest.id"
class="flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-all duration-150 hover:shadow-md"
:class="[
selectedRestaurantId === rest.id
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
: 'border-gray-200 hover:border-gray-300'
]"
>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<p class="font-medium text-gray-900">{{ rest.name }}</p>
<p class="text-sm text-gray-500">{{ rest.host }}</p>
</div>
</div>
<div v-if="selectedRestaurantId === rest.id" class="text-primary-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div v-if="filteredRestaurants.length === 0" class="text-center py-8 text-gray-500">
{{ t('olapColumns.noRestaurantsFound') }}
</div>
</div>
<!-- Кнопки действий -->
<div class="flex justify-end space-x-3 p-6 border-t bg-gray-50 rounded-b-2xl">
<button @click="closeInitModal" class="btn-secondary">{{ t('app.cancel') }}</button>
<button
@click="onInitConfirm"
:disabled="!selectedRestaurantId"
class="btn-primary"
>
{{ t('app.confirm') }}
</button>
</div>
</div>
</div>
</div>
</Transition>
<!-- Модалка предупреждения перед обновлением (после выбора ресторана) -->
<Transition name="fade">
<div v-if="refreshWarningModal.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="refreshWarningModal.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">{{ t('olapColumns.refreshWarningTitle') }}</h3>
<p class="text-sm text-gray-500 mb-4">
{{ t('olapColumns.refreshWarningMessage', { restaurant: pendingRestaurantName }) }}
</p>
<p class="text-sm font-semibold text-red-600 mb-6">{{ t('olapColumns.refreshWarningConfirm') }}</p>
<div class="flex justify-center space-x-3">
<button @click="refreshWarningModal.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="executeInitialize" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
{{ t('app.confirm') }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
<!-- Модалка подтверждения удаления поля -->
<Transition name="fade">
<div v-if="deleteFieldConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteFieldConfirm.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">{{ t('olapColumns.deleteField') }}</h3>
<p class="text-sm text-gray-500 mb-6">{{ t('olapColumns.deleteFieldConfirm') }}</p>
<div class="flex justify-center space-x-3">
<button @click="deleteFieldConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="confirmDeleteField" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">{{ t('app.delete') }}</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
<Transition name="fade">
<div v-if="initializing" class="fixed inset-0 z-60 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-xl p-8 flex flex-col items-center gap-4 max-w-sm w-full mx-4">
<svg class="animate-spin h-12 w-12 text-primary-600" 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="text-gray-700 font-medium">
{{ initializingText }}
</p>
<p class="text-sm text-gray-500 text-center">
{{ t('olapColumns.waitMessage') }}
</p>
</div>
</div>
</Transition>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
interface Column {
fieldKey: string;
fieldKeyNormal: string;
reportTypes: string[];
name: string;
type: string;
aggregationAllowed: boolean;
groupingAllowed: boolean;
filteringAllowed: boolean;
tags: string[];
}
interface Restaurant {
id: number;
name: string;
host: string;
}
const columns = ref<Column[]>([]);
const loading = ref(false);
const initializing = ref(false);
const initModalOpen = ref(false);
const initModalTitle = ref('');
const restaurants = ref<Restaurant[]>([]);
const selectedRestaurantId = ref<number | null>(null);
const pendingRestaurantId = ref<number | null>(null);
const pendingRestaurantName = ref('');
const refreshWarningModal = ref({ show: false });
const initializingText = ref('');
const filters = ref({
fieldKey: '',
reportType: '',
tag: ''
});
const deleteFieldConfirm = ref({ show: false, fieldKey: '' });
const hasData = computed(() => columns.value.length > 0);
const availableReportTypes = computed(() => {
const types = new Set<string>();
for (const col of columns.value) {
for (const rt of col.reportTypes) {
types.add(rt);
}
}
return Array.from(types).sort();
});
const availableTags = computed(() => {
const tags = new Set<string>();
for (const col of columns.value) {
for (const tag of col.tags) {
tags.add(tag);
}
}
return Array.from(tags).sort();
});
const filteredColumns = computed(() => {
let result = columns.value;
if (filters.value.fieldKey) {
const lowerKey = filters.value.fieldKey.toLowerCase();
result = result.filter(col => col.fieldKey.toLowerCase().includes(lowerKey));
}
if (filters.value.reportType) {
result = result.filter(col => col.reportTypes.includes(filters.value.reportType));
}
if (filters.value.tag) {
result = result.filter(col => col.tags.includes(filters.value.tag));
}
return result;
});
async function loadColumns() {
loading.value = true;
try {
const res = await fetch('/api/reports/olap/columns');
if (!res.ok) {
if (res.status === 404 || res.status === 204) {
columns.value = [];
} else {
throw new Error(`HTTP ${res.status}`);
}
} else {
const data = await res.json();
columns.value = data.columns || [];
}
} catch (error) {
console.error(error);
showNotification('olapColumns.loadError', 'error');
columns.value = [];
} finally {
loading.value = false;
}
}
async function loadRestaurants() {
try {
const res = await fetch('/api/admin/restaurants');
if (res.ok) {
const data = await res.json();
restaurants.value = data;
} else {
showNotification('restaurants.loadError', 'error');
}
} catch (error) {
showNotification('restaurants.loadError', 'error');
}
}
const restaurantSearch = ref('');
const filteredRestaurants = computed(() => {
if (!restaurantSearch.value) return restaurants.value;
const lower = restaurantSearch.value.toLowerCase();
return restaurants.value.filter(r =>
r.name.toLowerCase().includes(lower) ||
r.host.toLowerCase().includes(lower)
);
});
function openInitModal() {
initModalTitle.value = t('olapColumns.selectRestaurant');
loadRestaurants().then(() => { initModalOpen.value = true; });
}
function openRefreshModal() {
initModalTitle.value = t('olapColumns.refreshStructure');
loadRestaurants().then(() => { initModalOpen.value = true; });
}
function closeInitModal() {
initModalOpen.value = false;
selectedRestaurantId.value = null;
}
function onInitConfirm() {
if (!selectedRestaurantId.value) return;
const selectedRest = restaurants.value.find(r => r.id === selectedRestaurantId.value);
pendingRestaurantName.value = selectedRest ? selectedRest.name : '';
pendingRestaurantId.value = selectedRestaurantId.value;
if (hasData.value) {
closeInitModal();
refreshWarningModal.value.show = true;
} else {
executeInitialize();
}
}
async function executeInitialize() {
const id = pendingRestaurantId.value ?? selectedRestaurantId.value;
if (!id) {
showNotification('olapColumns.selectRestaurantFirst', 'error');
return;
}
initModalOpen.value = false;
refreshWarningModal.value.show = false;
deleteFieldConfirm.value.show = false;
initializingText.value = hasData.value ? t('olapColumns.refreshingData') : t('olapColumns.initializingData');
initializing.value = true;
try {
const res = await fetch('/api/reports/olap/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ restaurantId: id })
});
if (!res.ok) {
const errText = await res.text();
throw new Error(errText || `HTTP ${res.status}`);
}
showNotification('olapColumns.initSuccess', 'success');
await loadColumns();
} catch (error: any) {
showNotification('olapColumns.initError', 'error', { error: error.message });
} finally {
initializing.value = false;
initializingText.value = '';
pendingRestaurantId.value = null;
pendingRestaurantName.value = '';
selectedRestaurantId.value = null;
}
}
function openDeleteFieldModal(fieldKey: string) {
deleteFieldConfirm.value = { show: true, fieldKey };
}
async function confirmDeleteField() {
const fieldKey = deleteFieldConfirm.value.fieldKey;
if (!fieldKey) return;
try {
const res = await fetch(`/api/reports/olap/columns/${encodeURIComponent(fieldKey)}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error();
showNotification('olapColumns.deleteSuccess', 'success');
deleteFieldConfirm.value.show = false;
await loadColumns();
} catch (error) {
showNotification('olapColumns.deleteError', 'error');
}
}
function resetFilters() {
filters.value = { fieldKey: '', reportType: '', tag: '' };
}
onMounted(() => {
loadColumns();
});
</script>
<style scoped>
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.z-60 {
z-index: 60;
}
</style>
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
<template>
<AppLayout>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ t('olapQueries.title') }}</h1>
<router-link to="/olap/constructor" class="btn-primary">+ {{ t('olapQueries.createButton') }}</router-link>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.active') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapQueries.lastRun') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapQueries.result') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapQueries.connection') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('app.restaurants') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="q in queries" :key="q.id" class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm">{{ q.id }}</td>
<td class="px-6 py-4 text-sm font-medium">{{ q.name }}</td>
<td class="px-6 py-4 text-sm">
<span :class="q.active ? 'text-green-600' : 'text-red-600'">
{{ q.active ? t('common.yes') : t('common.no') }}
</span>
</td>
<td class="px-6 py-4 text-sm">{{ q.lastRun ? formatDate(q.lastRun) : '—' }}</td>
<td class="px-6 py-4 text-sm">
<span v-if="q.lastRunSuccess === null"></span>
<span v-else-if="q.lastRunSuccess" class="text-green-600">{{ t('olapQueries.success') }}</span>
<span v-else class="text-red-600">{{ t('olapQueries.error') }}</span>
</td>
<td class="px-6 py-4 text-sm">{{ q.dbConnectionName }}</td>
<td class="px-6 py-4 text-sm">{{ q.restaurants }}</td>
<td class="px-6 py-4 text-sm">{{ formatDate(q.created) }}</td>
<td class="px-6 py-4 text-right space-x-2">
<router-link :to="`/olap/constructor/${q.id}`" class="text-blue-600 hover:text-blue-800">
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</router-link>
<button @click="confirmDelete(q.id)" class="text-red-600 hover:text-red-800">
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
<tr v-if="queries.length === 0">
<td colspan="6" class="px-6 py-12 text-center text-gray-500">{{ t('olapQueries.noQueries') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модальное окно удаления с улучшенной стилизацией -->
<Teleport to="body">
<Transition name="fade">
<div v-if="deleteModal.show" class="fixed inset-0 z-[9999] overflow-y-auto" @click.self="deleteModal.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 transform transition-all">
<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">{{ t('olapQueries.deleteQueriesTitle') }}</h3>
<p class="text-sm text-gray-500 mb-6">{{ t('olapQueries.deleteQueriesMessage') }}</p>
<div class="flex justify-center space-x-3">
<button @click="deleteModal.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="deleteQuery" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/Layout/AppLayout.vue'
import { useNotification } from '@/composables/useNotification'
const { t } = useI18n()
const { showNotification } = useNotification()
const queries = ref([])
const deleteModal = ref({ show: false, id: null as number | null })
async function loadQueries() {
try {
const res = await fetch('/api/olap/queries')
if (!res.ok) throw new Error()
queries.value = await res.json()
} catch (e) {
showNotification('olapQueries.loadError', 'error')
}
}
function formatDate(dateStr: string | null) {
return dateStr ? new Date(dateStr).toLocaleString() : '-'
}
function confirmDelete(id: number) {
deleteModal.value = { show: true, id }
}
async function deleteQuery() {
const id = deleteModal.value.id
if (!id) return
try {
const res = await fetch(`/api/olap/queries/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error()
showNotification('olapQueries.deleteSuccess', 'success')
await loadQueries()
} catch (e) {
showNotification('olapQueries.deleteError', 'error')
} finally {
deleteModal.value.show = false
}
}
onMounted(loadQueries)
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+5 -3
View File
@@ -84,10 +84,12 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { useUserStore } from '../stores/user';
import { useUserStore } from '@/stores/user';
import { useI18n } from 'vue-i18n';
import AppLayout from '../components/Layout/AppLayout.vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import {useNotification} from "@/composables/useNotification";
const { showNotification } = useNotification();
const userStore = useUserStore();
const { t, locale } = useI18n();
@@ -129,7 +131,7 @@ async function saveProfile() {
if (ok) {
locale.value = form.language;
showNotification('profile.updateSuccess', 'success');
resetForm(); // очищаем поля пароля
resetForm();
} else {
showNotification('profile.updateError', 'error');
}
+2 -2
View File
@@ -153,9 +153,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n';
import { useNotification } from '../composables/useNotification';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
+6 -22
View File
@@ -36,7 +36,7 @@
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="user.id === currentUserId" class="text-xs text-gray-500">{{ t('users.you') }}</div>
<div v-if="user.id === userStore.id" class="text-xs text-gray-500">{{ t('users.you') }}</div>
<label v-else class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
@@ -57,7 +57,7 @@
</svg>
</button>
<button
v-if="user.id !== currentUserId"
v-if="user.id !== userStore.id"
@click="confirmDelete(user.id)"
class="text-red-600 hover:text-red-800 transition-colors"
>
@@ -155,16 +155,15 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import { useUserStore } from '../stores/user';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useUserStore } from '@/stores/user';
import { useI18n } from 'vue-i18n';
import { useNotification } from '../composables/useNotification';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
const userStore = useUserStore();
const currentUserId = ref<number | null>(null);
const users = ref<any[]>([]);
const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create');
@@ -173,23 +172,9 @@ const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null });
const isEditingSelf = computed(() => {
return modalMode.value === 'edit' && form.value.id === currentUserId.value;
return modalMode.value === 'edit' && form.value.id === userStore.id;
});
async function loadCurrentUser() {
try {
const res = await fetch('/api/admin/me');
if (res.ok) {
const data = await res.json();
currentUserId.value = data.id;
} else {
showNotification('users.loadCurrentError', 'error');
}
} catch (e) {
showNotification('common.networkError', 'error');
}
}
async function loadUsers() {
try {
const res = await fetch('/api/admin/users');
@@ -297,7 +282,6 @@ async function deleteUser(id: number) {
}
onMounted(async () => {
await loadCurrentUser();
await loadUsers();
});
</script>
+10 -2
View File
@@ -96,6 +96,12 @@
</div>
</transition>
</div>
<!-- Блок версии внизу -->
<div class="mt-6 text-center text-xs text-gray-500">
{{ versionStore.getFormattedVersion(t) }}
</div>
</div>
</div>
</template>
@@ -103,14 +109,16 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSettingsStore } from '../../stores/settings'
import { useUserStore } from '../../stores/user'
import { useSettingsStore } from '@/stores/settings'
import { useUserStore } from '@/stores/user'
import { useVersionStore } from '@/stores/version'
import { useI18n } from 'vue-i18n'
const settings = useSettingsStore()
const userStore = useUserStore()
const router = useRouter()
const { t, locale } = useI18n()
const versionStore = useVersionStore()
const form = ref({ login: '', password: '' })
const loading = ref(false)
const error = ref('')
+8
View File
@@ -35,6 +35,12 @@
{{ t('register.alreadyHaveAccount') }} <router-link to="/login" class="text-primary-600">{{ t('login.signin') }}</router-link>
</p>
</div>
<!-- Блок версии внизу -->
<div class="mt-6 text-center text-xs text-gray-500">
{{ versionStore.getFormattedVersion(t) }}
</div>
</div>
</div>
</template>
@@ -42,7 +48,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVersionStore } from '@/stores/version'
const { t } = useI18n()
const versionStore = useVersionStore()
const form = ref({ login: '', email: '', password: '' })
const loading = ref(false)
const error = ref('')
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"jsx": "preserve",
"jsxImportSource": "vue",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules"]
}
+5
View File
@@ -3,6 +3,11 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': '/src',
},
},
server: {
proxy: {
'/api': 'http://localhost:8080' // для разработки
-32
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;
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,51 @@
package su.xserver.iikocon;
import io.vertx.core.json.JsonObject;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import java.util.logging.Logger;
public class BuildVersionProvider {
private static final Logger LOG = Logger.getLogger(BuildVersionProvider.class.getName());
private final JsonObject cachedVersion;
public BuildVersionProvider() {
this.cachedVersion = loadVersionData();
}
private JsonObject loadVersionData() {
Properties props = new Properties();
try (InputStream is = getClass().getClassLoader().getResourceAsStream("version.properties")) {
if (is == null) {
LOG.warning("version.properties not found in classpath");
return createFallbackVersion();
}
props.load(is);
} catch (IOException e) {
LOG.severe("Failed to read version.properties: " + e.getMessage());
return createFallbackVersion();
}
String version = props.getProperty("version", "0.0.0");
String commitHash = props.getProperty("commit.hash", "unknown");
String buildTime = props.getProperty("build.time", "");
return new JsonObject()
.put("version", version)
.put("commitHash", commitHash)
.put("buildTime", buildTime);
}
private JsonObject createFallbackVersion() {
return new JsonObject()
.put("version", "0.0.0-dev")
.put("commitHash", "unknown")
.put("buildTime", "1970-01-01T00:00:00Z");
}
public JsonObject getVersion() {
return cachedVersion;
}
}
@@ -6,43 +6,59 @@ import io.vertx.config.ConfigStoreOptions;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpServer;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.client.HttpRequest;
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.handler.BodyHandler;
import io.vertx.ext.web.handler.SessionHandler;
import io.vertx.ext.web.handler.StaticHandler;
import io.vertx.ext.web.sstore.LocalSessionStore;
import io.vertx.ext.web.sstore.SessionStore;
import io.vertx.ext.web.sstore.redis.RedisSessionStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.config.AppConfig;
import su.xserver.iikocon.handler.AdminHandler;
import su.xserver.iikocon.handler.AuthHandler;
import su.xserver.iikocon.handler.SecurityHandler;
import su.xserver.iikocon.handler.SetupHandler;
import su.xserver.iikocon.handler.*;
import su.xserver.iikocon.iiko.IikoHandler;
import su.xserver.iikocon.iiko.IikoOlapClient;
import su.xserver.iikocon.iiko.OlapQueryService;
import su.xserver.iikocon.service.*;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class MainVerticle extends AbstractVerticle {
private final Logger log = LoggerFactory.getLogger("[MainVerticle]");
private BuildVersionProvider versionProvider;
private DataBaseService db;
private RedisService redis;
private HttpServer httpServer;
private AppConfig config;
private SessionStore sessionStore;
private UserService userService;
private RestaurantService restaurantService;
private ExternalDataBaseService externalDataBaseService;
private SettingsService settingsService;
private OlapQueryService olapQueryService;
@Override
public void start(Promise<Void> startPromise) {
public void start(Promise<Void> startPromise) throws ClassNotFoundException {
Class.forName("com.mysql.cj.jdbc.Driver");
Class.forName("org.postgresql.Driver");
versionProvider = new BuildVersionProvider();
ConfigStoreOptions classpathStore = new ConfigStoreOptions()
.setType("file")
@@ -62,12 +78,12 @@ public class MainVerticle extends AbstractVerticle {
db = new DataBaseService(vertx, config.database);
redis = new RedisService(vertx, config.redis);
// Инициализация сервисов
userService = new UserService(db.getPool());
restaurantService = new RestaurantService(db.getPool());
settingsService = new SettingsService(db.getPool());
externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx);
olapQueryService = new OlapQueryService(db.getPool(), externalDataBaseService);
// Инициализация БД (создание таблицы users)
userService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
@@ -80,6 +96,14 @@ public class MainVerticle extends AbstractVerticle {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
externalDataBaseService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
olapQueryService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
createRouterAndStartHttp(startPromise);
@@ -91,7 +115,7 @@ public class MainVerticle extends AbstractVerticle {
private void createRouterAndStartHttp(Promise<Void> startPromise) {
settingsService.get("session_timeout_minutes")
.compose(timeoutStr -> {
long timeoutMinutes = 60; // default
long timeoutMinutes = 60;
if (timeoutStr != null && !timeoutStr.isEmpty()) {
try {
timeoutMinutes = Long.parseLong(timeoutStr);
@@ -99,7 +123,7 @@ public class MainVerticle extends AbstractVerticle {
}
long timeoutMs = timeoutMinutes * 60 * 1000;
SessionStore sessionStore = LocalSessionStore.create(vertx);
sessionStore = RedisSessionStore.create(vertx, redis.getRedis());
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionCookieName("admin.session")
.setCookieHttpOnlyFlag(true)
@@ -116,15 +140,95 @@ public class MainVerticle extends AbstractVerticle {
});
}
private void setupPhpmyadminProxy(Router router) {
if (config.pma == null || !config.pma.enabled) return;
String upstream = config.pma.upstream;
String basePath = config.pma.basePath;
final URI upstreamUri = URI.create(upstream);
final String host = upstreamUri.getHost();
int portTmp = upstreamUri.getPort();
if (portTmp == -1) {
portTmp = "https".equals(upstreamUri.getScheme()) ? 443 : 80;
}
final int port = portTmp;
final WebClient webClient = WebClient.create(vertx);
router.route(basePath + "/*").handler(ctx -> {
if (ctx.session() != null && "admin".equals(ctx.session().get("role"))) {
ctx.next();
} else {
ctx.response().putHeader("Location", "/").setStatusCode(302).end();
}
});
router.route(basePath + "/*").handler(ctx -> {
String targetPathBase = ctx.request().path().substring(basePath.length());
if (targetPathBase.isEmpty()) targetPathBase = "/";
String targetPath = targetPathBase;
String query = ctx.request().query();
if (query != null && !query.isEmpty()) {
targetPath += "?" + query;
}
final String targetPathFinal = targetPath;
final HttpRequest<Buffer> proxyReq = webClient.request(
ctx.request().method(), port, host, targetPathFinal
);
ctx.request().headers().forEach(header -> {
if (!"host".equalsIgnoreCase(header.getKey())) {
proxyReq.putHeader(header.getKey(), header.getValue());
}
});
proxyReq.putHeader("Host", host + ":" + port);
ctx.request().bodyHandler(body -> {
if (body != null && body.length() > 0) {
proxyReq.sendBuffer(body)
.onSuccess(resp -> sendResponse(ctx, resp))
.onFailure(err -> sendError(ctx, err));
} else {
proxyReq.send()
.onSuccess(resp -> sendResponse(ctx, resp))
.onFailure(err -> sendError(ctx, err));
}
});
});
}
private void sendResponse(RoutingContext ctx, HttpResponse<Buffer> resp) {
ctx.response().setStatusCode(resp.statusCode());
resp.headers().forEach(h -> ctx.response().putHeader(h.getKey(), h.getValue()));
ctx.response().end(resp.body());
}
private void sendError(RoutingContext ctx, Throwable err) {
log.error("Proxy error: {}", err.getMessage());
ctx.response().setStatusCode(502).end("Bad Gateway: " + err.getMessage());
}
private Router initRouter(SessionHandler sessionHandler) {
Router router = Router.router(vertx);
router.route().handler(BodyHandler.create());
router.route().handler(ctx -> {
String path = ctx.request().path();
if (path != null && path.startsWith(config.pma.basePath + "/")) {
ctx.next(); // пропускаем BodyHandler для прокси
} else {
BodyHandler.create().handle(ctx);
}
});
router.route().handler(sessionHandler);
setupPhpmyadminProxy(router);
SecurityHandler securityHandlers = new SecurityHandler(settingsService);
// Обработчики безопасности (порядок важен)
// Обработчики безопасности
router.route().handler(securityHandlers.hostValidator());
router.route().handler(securityHandlers.proxyHeadersHandler());
router.route().handler(securityHandlers.cspHeader());
@@ -144,6 +248,21 @@ public class MainVerticle extends AbstractVerticle {
}
});
router.route().handler(ctx -> {
long start = System.currentTimeMillis();
String method = ctx.request().method().name();
String path = ctx.request().path();
final String remoteIp = ctx.get("realClientIp") != null ?
ctx.get("realClientIp") :
ctx.request().remoteAddress().host();
ctx.addBodyEndHandler(v -> {
long duration = System.currentTimeMillis() - start;
log.info("{} {} - {} ms - {} - {}",
method, path, duration, ctx.response().getStatusCode(), remoteIp);
});
ctx.next();
});
// ------ Раздаём Vue статику ------
router.route("/assets/*").handler(StaticHandler.create("webroot/assets"));
@@ -160,6 +279,20 @@ public class MainVerticle extends AbstractVerticle {
}
});
router.get("/api/build-info").handler(rc -> {
JsonObject version = versionProvider.getVersion();
rc.response()
.putHeader("Content-Type", "application/json")
.end(version.encode());
});
// Rate Limiter Handler
RedisRateLimiter limiter = new RedisRateLimiter(
redis.getRedis(), 60, 60_000
);
router.route().handler(limiter);
// Health Checks
HealthCheckService healthCheckService = new HealthCheckService(vertx, redis, db);
healthCheckService.registerHealthCheck(router);
@@ -181,7 +314,6 @@ public class MainVerticle extends AbstractVerticle {
rc.response().setStatusCode(403).end(new JsonObject().put("error", "Registration is disabled").encode());
return;
}
// существующий код регистрации
JsonObject body = rc.body().asJsonObject();
String login = body.getString("login");
String email = body.getString("email");
@@ -196,15 +328,14 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}));
// В initRouter после настройки authHandler, до объявления /api/admin/*:
router.route("/api/admin/profile").handler(authHandler::requireAuth);
router.get("/api/admin/profile").handler(rc -> {
router.route("/api/profile").handler(authHandler::requireAuth);
router.get("/api/profile").handler(rc -> {
Integer userId = rc.session().get("userId");
userService.getProfile(userId)
.onSuccess(profile -> rc.response().putHeader("Content-Type", "application/json").end(profile.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/profile").handler(rc -> {
router.put("/api/profile").handler(rc -> {
Integer userId = rc.session().get("userId");
JsonObject body = rc.body().asJsonObject();
String email = body.getString("email");
@@ -217,30 +348,8 @@ public class MainVerticle extends AbstractVerticle {
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/language").handler(rc -> {
Integer userId = rc.session().get("userId");
JsonObject body = rc.body().asJsonObject();
String language = body.getString("language");
if (language == null || (!"en".equals(language) && !"ru".equals(language))) {
rc.response().setStatusCode(400).end("Invalid language");
return;
}
userService.updateLanguage(userId, language)
.onSuccess(v -> {
rc.session().put("language", language);
rc.response().end(new JsonObject().put("success", true).encode());
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Затем существующий блок router.route("/api/admin/*").handler(authHandler::requireAuth);
router.route("/api/admin/*").handler(authHandler::requireAuth);
// Добавить проверку роли для чувствительных эндпоинтов:
// router.route("/api/admin/users*").handler(AdminHandler::requireAdmin);
// router.route("/api/admin/restaurants*").handler(AdminHandler::requireAdmin);
// router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin);
// router.route("/api/admin/active-sessions").handler(AdminHandler::requireAdmin);
router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> {
if (ar.succeeded()) {
rc.response()
@@ -251,6 +360,7 @@ public class MainVerticle extends AbstractVerticle {
}
}));
router.route("/api/admin/users*").handler(AdminHandler::requireAdmin);
router.post("/api/admin/users").handler(rc -> {
JsonObject body = rc.body().asJsonObject();
String login = body.getString("login");
@@ -316,21 +426,6 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Получение текущего пользователя
router.get("/api/admin/me").handler(rc -> {
Integer userId = rc.session().get("userId");
if (userId != null) {
rc.response()
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("id", userId)
.put("login", rc.session().get("login"))
.encode());
} else {
rc.response().setStatusCode(401).end();
}
});
router.get("/api/admin/restaurants").handler(rc -> restaurantService.getAllRestaurants().onComplete(ar -> {
if (ar.succeeded()) {
rc.response()
@@ -409,28 +504,25 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Получение всех настроек
router.get("/api/settings").handler(rc -> {
settingsService.getPublicSettings()
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end());
});
// Получить метаданные всех настроек (для построения формы)
router.get("/api/settings/meta").handler(rc -> {
router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin);
router.get("/api/admin/settings/meta").handler(rc -> {
settingsService.getMetadata()
.onSuccess(meta -> rc.response().putHeader("Content-Type", "application/json").end(meta.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Получить все настройки со значениями по умолчанию
router.get("/api/settings/all").handler(rc -> {
router.get("/api/admin/settings").handler(rc -> {
settingsService.getAllWithDefaults()
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Обновление настроек (админ)
router.put("/api/admin/settings").handler(rc -> {
JsonObject body = rc.body().asJsonObject();
List<Future<Void>> futures = new ArrayList<>(); // явно указываем тип Future<Void>
@@ -440,17 +532,139 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Количество активных сессий (на основе Redis)
router.get("/api/admin/active-sessions").handler(rc -> {
// TODO: реализовать подсчёт активных сессий через Redis или другой механизм
rc.response().end(new JsonObject().put("count", 0).encode());
externalDataBaseService.handleRoute(router);
new IikoHandler(vertx, router, db, restaurantService, authHandler);
// Роуты для OLAP запросов
router.get("/api/olap/queries").handler(authHandler::requireAuth).handler(rc -> {
olapQueryService.getAllQueries()
.onSuccess(queries -> rc.response().putHeader("Content-Type", "application/json").end(queries.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.get("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
olapQueryService.getQueryById(id)
.onSuccess(query -> {
if (query == null) rc.response().setStatusCode(404).end();
else rc.response().putHeader("Content-Type", "application/json").end(query.encode());
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.post("/api/olap/queries").handler(authHandler::requireAuth).handler(rc -> {
JsonObject body = rc.body().asJsonObject();
String name = body.getString("name");
Integer dbConnectionId = body.getInteger("dbConnectionId");
JsonObject config = body.getJsonObject("config");
JsonArray restaurantIdsArray = body.getJsonArray("restaurantIds", new JsonArray());
List<Integer> restaurantIds = restaurantIdsArray.stream().map(id -> (Integer) id).collect(Collectors.toList());
// Получаем active: сначала из тела, иначе из конфига, иначе true
Boolean active = body.getBoolean("active");
if (active == null && config != null) active = config.getBoolean("active", true);
if (active == null) active = true;
if (name == null || dbConnectionId == null || config == null) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
String tableName = config.getString("tableName");
if (tableName.isEmpty()) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
if (olapQueryService.isValidTableName(tableName)) {
rc.response().setStatusCode(400).end("Invalid tableName: must start with a letter and contain only letters and digits");
return;
}
Boolean finalActive = active;
olapQueryService.generateSql(config, dbConnectionId)
.compose(sql -> olapQueryService.createQuery(name, dbConnectionId, config, restaurantIds, sql, finalActive))
.onSuccess(id -> rc.response().setStatusCode(201).putHeader("Content-Type", "application/json").end(new JsonObject().put("id", id).encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
JsonObject body = rc.body().asJsonObject();
String name = body.getString("name");
Integer dbConnectionId = body.getInteger("dbConnectionId");
JsonObject config = body.getJsonObject("config");
JsonArray restaurantIdsArray = body.getJsonArray("restaurantIds", new JsonArray());
List<Integer> restaurantIds = restaurantIdsArray.stream().map(v -> (Integer) v).collect(Collectors.toList());
Boolean active = body.getBoolean("active");
if (active == null && config != null) active = config.getBoolean("active", true);
if (active == null) active = true;
if (name == null || dbConnectionId == null || config == null) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
String tableName = config.getString("tableName");
if (tableName.isEmpty()) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
if (olapQueryService.isValidTableName(tableName)) {
rc.response().setStatusCode(400).end("Invalid tableName: must start with a letter and contain only letters and digits");
return;
}
Boolean finalActive = active;
olapQueryService.generateSql(config, dbConnectionId)
.compose(sql -> olapQueryService.updateQuery(id, name, dbConnectionId, config, restaurantIds, sql, finalActive))
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.delete("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
olapQueryService.deleteQuery(id)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.post("/api/olap/generate-sql").handler(authHandler::requireAuth).handler(rc -> {
JsonObject body = rc.body().asJsonObject();
JsonObject config = body.getJsonObject("config");
Integer dbConnectionId = body.getInteger("dbConnectionId");
if (config == null || dbConnectionId == null) {
rc.response().setStatusCode(400).end("Missing config or dbConnectionId");
return;
}
olapQueryService.generateSql(config, dbConnectionId)
.onSuccess(sql -> rc.response().putHeader("Content-Type", "text/plain").end(sql))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.post("/api/olap/export-json").handler(authHandler::requireAuth).handler(rc -> {
JsonObject body = rc.body().asJsonObject();
JsonObject config = body.getJsonObject("config");
if (config == null) {
rc.response().setStatusCode(400).end("Missing config");
return;
}
JsonObject fullJson = olapQueryService.generateFullIikoJson(config);
rc.response()
.putHeader("Content-Type", "application/json")
.putHeader("Content-Disposition", "attachment; filename=olap_export.json")
.end(fullJson.encodePrettily());
});
return router;
}
private void startHttp(Router router, Promise<Void> startPromise) {
// Запуск HTTP-сервера
httpServer = vertx.createHttpServer();
httpServer.requestHandler(router).listen(config.server.port, config.server.host)
.onSuccess(server -> {
@@ -8,6 +8,7 @@ public class AppConfig {
public ServerConfig server;
public DatabaseConfig database;
public RedisConfig redis;
public PhpMyAdminConfig pma;
public static AppConfig from(JsonObject json) {
JsonObject resolved = json.copy();
@@ -94,7 +95,8 @@ public class AppConfig {
return new JsonObject()
.put("server", server.json().getJsonObject("server"))
.put("database", database.json().getJsonObject("database"))
.put("redis", redis.json().getJsonObject("redis"));
.put("redis", redis.json().getJsonObject("redis"))
.put("pma", pma.json().getJsonObject("pma"));
}
@Override
@@ -102,4 +104,3 @@ public class AppConfig {
return json().encode();
}
}
@@ -0,0 +1,18 @@
package su.xserver.iikocon.config;
import io.vertx.core.json.JsonObject;
public class PhpMyAdminConfig {
public boolean enabled;
public String upstream;
public String basePath;
public JsonObject json() {
return new JsonObject()
.put("pma", new JsonObject()
.put("enabled", enabled)
.put("upstream", upstream)
.put("basePath", basePath)
);
}
}
@@ -70,7 +70,6 @@ public class AuthHandler {
if (session != null) {
session.destroy();
}
// Явное удаление cookie сессии
ctx.response().removeCookie("admin.session");
ctx.response().end(new JsonObject().put("success", true).encode());
}
@@ -0,0 +1,185 @@
package su.xserver.iikocon.handler;
import io.vertx.core.Handler;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import io.vertx.redis.client.Command;
import io.vertx.redis.client.Redis;
import io.vertx.redis.client.Request;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class RedisRateLimiter implements Handler<RoutingContext> {
private final Logger logger;
private final Redis redis;
private final int limitPerWindow;
private final long windowMillis;
private static final String PREFIX = "ip:limit:";
// Основной кэш: clientKey -> время окончания блокировки
private final ConcurrentHashMap<String, Long> blockedClients = new ConcurrentHashMap<>();
// Индекс по времени: время окончания -> множество клиентов
private final ConcurrentSkipListMap<Long, Set<String>> expiryIndex = new ConcurrentSkipListMap<>();
private final ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor();
private final AtomicLong allowedRequests = new AtomicLong(0);
private final AtomicLong blockedRequests = new AtomicLong(0);
private final AtomicLong redisCalls = new AtomicLong(0);
private final AtomicLong redisFailures = new AtomicLong(0);
private final AtomicLong totalRedisLatency = new AtomicLong(0);
private final AtomicLong redisLatencyCount = new AtomicLong(0);
// Частота блокировок по IP
private final ConcurrentHashMap<String, AtomicLong> blockedByClient = new ConcurrentHashMap<>();
public RedisRateLimiter(Redis redis, int limitPerWindow, long windowMillis) {
this.logger = LoggerFactory.getLogger("[RateLimiter]");
this.redis = redis;
this.limitPerWindow = limitPerWindow;
this.windowMillis = windowMillis;
// Периодическая очистка только истёкших блокировок
cleaner.scheduleAtFixedRate(this::cleanupExpiredClients, windowMillis, windowMillis / 2, TimeUnit.MILLISECONDS);
}
@Override
public void handle(RoutingContext context) {
String clientKey = getClientKey(context);
long now = System.currentTimeMillis();
// Проверяем локальную блокировку
Long blockedUntil = blockedClients.get(clientKey);
if (blockedUntil != null) {
if (blockedUntil > now) {
blockedRequests.incrementAndGet();
incrementBlockCount(clientKey);
sendTooManyRequests(context);
return;
} else {
unblockClient(clientKey, blockedUntil);
}
}
String redisKey = PREFIX + clientKey;
checkRateLimit(context, redisKey, clientKey);
}
private void checkRateLimit(RoutingContext context, String redisKey, String clientKey) {
String luaScript = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('PEXPIRE', key, ttl)
end
if current > limit then
return 'TOO_MANY_REQUESTS'
else
return 'OK'
end
""";
redisCalls.incrementAndGet();
long start = System.nanoTime();
Request request = Request.cmd(Command.EVAL)
.arg(luaScript)
.arg(1)
.arg(redisKey)
.arg(limitPerWindow)
.arg(windowMillis);
redis.send(request)
.onSuccess(response -> {
long duration = System.nanoTime() - start;
redisLatencyCount.incrementAndGet();
totalRedisLatency.addAndGet(TimeUnit.NANOSECONDS.toMillis(duration));
String result = response.toString();
if ("TOO_MANY_REQUESTS".equals(result)) {
blockClient(clientKey);
blockedRequests.incrementAndGet();
incrementBlockCount(clientKey);
sendTooManyRequests(context);
} else {
allowedRequests.incrementAndGet();
context.next();
}
}).onFailure(error -> {
redisFailures.incrementAndGet();
context.response()
.setStatusCode(503)
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("error", "503 Service Unavailable")
.put("message", "Redis is not connected")
.encodePrettily()
);
logger.error(error.getMessage());
});
}
private void blockClient(String clientKey) {
long blockedUntil = System.currentTimeMillis() + windowMillis;
blockedClients.put(clientKey, blockedUntil);
expiryIndex.computeIfAbsent(blockedUntil, t -> ConcurrentHashMap.newKeySet()).add(clientKey);
}
private void unblockClient(String clientKey, long expiryTime) {
blockedClients.remove(clientKey);
Set<String> clients = expiryIndex.get(expiryTime);
if (clients != null) {
clients.remove(clientKey);
if (clients.isEmpty()) {
expiryIndex.remove(expiryTime);
}
}
}
private void incrementBlockCount(String clientKey) {
blockedByClient.computeIfAbsent(clientKey, k -> new AtomicLong(0)).incrementAndGet();
}
private void cleanupExpiredClients() {
long now = System.currentTimeMillis();
// Получаем все записи, у которых время истечения <= now
NavigableMap<Long, Set<String>> expired = expiryIndex.headMap(now, true);
if (expired.isEmpty()) return;
for (Map.Entry<Long, Set<String>> entry : expired.entrySet()) {
Set<String> clients = entry.getValue();
for (String client : clients) {
blockedClients.remove(client);
}
}
expired.clear(); // очищаем диапазон из индекса
}
private void sendTooManyRequests(RoutingContext context) {
context.response()
.setStatusCode(429)
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("error", "429 Too Many Requests")
.put("message", "Try again later")
.encodePrettily()
);
}
private String getClientKey(RoutingContext context) {
return context.request().remoteAddress().host().replace(':', '.');
}
}
@@ -0,0 +1,256 @@
package su.xserver.iikocon.iiko;
import io.vertx.core.Future;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.Tuple;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.handler.AdminHandler;
import su.xserver.iikocon.handler.AuthHandler;
import su.xserver.iikocon.service.DataBaseService;
import su.xserver.iikocon.service.RestaurantService;
public class IikoHandler {
private final Logger log = LoggerFactory.getLogger("[IikoHandler]");
private final DataBaseService db;
private final Vertx vertx;
private final RestaurantService restaurantService;
public IikoHandler(Vertx vertx, Router router, DataBaseService db, RestaurantService restaurantService, AuthHandler authHandler) {
this.vertx = vertx;
this.restaurantService = restaurantService;
this.db = db;
createTablesIfNotExist().onFailure(err -> log.error("Failed to initialize database", err));
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);
}
private void getColumns(RoutingContext ctx) {
getAllFieldsWithReportAndTags()
.onSuccess(ar -> ctx.response()
.putHeader("Content-Type", "application/json")
.end(ar.encode()))
.onFailure(err -> ctx.response()
.setStatusCode(500)
.end(err.getMessage()));
}
private void deleteColumn(RoutingContext ctx) {
String fieldKey = ctx.pathParam("fieldKey");
String sql = "DELETE FROM iiko_fields_common WHERE field_key = ?";
db.getPool().preparedQuery(sql)
.execute(Tuple.of(fieldKey))
.onSuccess(res -> ctx.end())
.onFailure(err -> ctx.response().setStatusCode(500).end(err.getMessage()));
}
private void postInitialize(RoutingContext ctx) {
JsonObject body = ctx.body().asJsonObject();
if (body == null) {
ctx.response()
.setStatusCode(400)
.end("Request body is missing or not a JSON object");
return;
}
if (!body.containsKey("restaurantId") || body.getValue("restaurantId") == null) {
ctx.response()
.setStatusCode(400)
.end("restaurantId is required");
return;
}
Integer restaurantId;
try {
restaurantId = body.getInteger("restaurantId");
if (restaurantId == null) {
throw new IllegalArgumentException("restaurantId must be a number");
}
} catch (ClassCastException e) {
ctx.response()
.setStatusCode(400)
.end("restaurantId must be a valid integer");
return;
}
restaurantService.findById(restaurantId)
.onSuccess(rest -> {
IikoOlapClient iiko = new IikoOlapClient(vertx, rest);
iiko.checkConnection()
.onSuccess(ping -> clearTables()
.onSuccess(data -> {
IikoOlapColumnsImporter importer = new IikoOlapColumnsImporter(iiko, db);
importer.fetchAndStoreAll()
.onSuccess(res -> ctx.end("OK"))
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage()));
})
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage())))
.onFailure(err -> ctx.response().setStatusCode(400).end(err.getMessage()));
})
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage()));
}
public Future<JsonObject> getAllFieldsWithReportAndTags() {
String sql = """
SELECT
fc.field_key,
fc.name,
fc.type,
fc.aggregation_allowed,
fc.grouping_allowed,
fc.filtering_allowed,
GROUP_CONCAT(DISTINCT rt.name ORDER BY rt.name SEPARATOR ',') AS report_names,
GROUP_CONCAT(DISTINCT t.tag_name ORDER BY t.tag_name SEPARATOR ',') AS tag_names
FROM iiko_fields_common fc
LEFT JOIN iiko_report_type_fields rtf ON fc.field_id = rtf.field_id
LEFT JOIN iiko_report_types rt ON rtf.report_type_id = rt.report_type_id
LEFT JOIN iiko_field_tags ft ON fc.field_id = ft.field_id
LEFT JOIN iiko_tags t ON ft.tag_id = t.tag_id
GROUP BY fc.field_id
ORDER BY fc.field_key
""";
return db.getPool().query(sql).execute()
.map(rows -> {
JsonArray columnsArray = new JsonArray();
for (Row row : rows) {
String reportNamesStr = row.getString("report_names");
JsonArray reportTypes = new JsonArray();
if (reportNamesStr != null && !reportNamesStr.isBlank()) {
for (String name : reportNamesStr.split(",")) {
reportTypes.add(name.trim());
}
}
String tagNamesStr = row.getString("tag_names");
JsonArray tags = new JsonArray();
if (tagNamesStr != null && !tagNamesStr.isBlank()) {
for (String tag : tagNamesStr.split(",")) {
tags.add(tag.trim());
}
}
JsonObject fieldObj = new JsonObject()
.put("fieldKey", row.getString("field_key"))
.put("reportTypes", reportTypes)
.put("name", row.getString("name"))
.put("type", row.getString("type"))
.put("aggregationAllowed", row.getBoolean("aggregation_allowed"))
.put("groupingAllowed", row.getBoolean("grouping_allowed"))
.put("filteringAllowed", row.getBoolean("filtering_allowed"))
.put("tags", tags);
columnsArray.add(fieldObj);
}
return new JsonObject().put("columns", columnsArray);
});
}
private Future<Void> createTablesIfNotExist() {
String createReportTypes = """
CREATE TABLE IF NOT EXISTS iiko_report_types (
report_type_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci
""";
String createFieldsCommon = """
CREATE TABLE IF NOT EXISTS iiko_fields_common (
field_id INT AUTO_INCREMENT PRIMARY KEY,
field_key VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
aggregation_allowed BOOLEAN NOT NULL DEFAULT 0,
grouping_allowed BOOLEAN NOT NULL DEFAULT 0,
filtering_allowed BOOLEAN NOT NULL DEFAULT 0
) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci
""";
String createReportTypeFields = """
CREATE TABLE IF NOT EXISTS iiko_report_type_fields (
report_type_id INT NOT NULL,
field_id INT NOT NULL,
PRIMARY KEY (report_type_id, field_id),
FOREIGN KEY (report_type_id) REFERENCES iiko_report_types(report_type_id) ON DELETE CASCADE,
FOREIGN KEY (field_id) REFERENCES iiko_fields_common(field_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci
""";
String createTags = """
CREATE TABLE IF NOT EXISTS iiko_tags (
tag_id INT AUTO_INCREMENT PRIMARY KEY,
tag_name VARCHAR(100) UNIQUE NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci
""";
String createFieldTags = """
CREATE TABLE IF NOT EXISTS iiko_field_tags (
field_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (field_id, tag_id),
FOREIGN KEY (field_id) REFERENCES iiko_fields_common(field_id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES iiko_tags(tag_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci
""";
String idxFieldKey = "CREATE INDEX IF NOT EXISTS idx_fields_common_key ON iiko_fields_common(field_key)";
String idxFieldName = "CREATE INDEX IF NOT EXISTS idx_fields_common_name ON iiko_fields_common(name)";
String idxFieldTagsTag = "CREATE INDEX IF NOT EXISTS idx_field_tags_tag_id ON iiko_field_tags(tag_id)";
return db.getPool().query(createReportTypes).execute()
.compose(v -> db.getPool().query(createFieldsCommon).execute())
.compose(v -> db.getPool().query(createReportTypeFields).execute())
.compose(v -> db.getPool().query(createTags).execute())
.compose(v -> db.getPool().query(createFieldTags).execute())
.compose(v -> db.getPool().query(idxFieldKey).execute())
.compose(v -> db.getPool().query(idxFieldName).execute())
.compose(v -> db.getPool().query(idxFieldTagsTag).execute())
.mapEmpty();
}
private Future<Void> clearTables() {
String sql = """
-- Отключаем проверку внешних ключей
SET FOREIGN_KEY_CHECKS = 0;
-- Удаляем данные из всех таблиц (порядок не важен при отключённой проверке)
DELETE FROM iiko_field_tags;
DELETE FROM iiko_report_type_fields;
DELETE FROM iiko_fields_common;
DELETE FROM iiko_tags;
DELETE FROM iiko_report_types;
-- Сбрасываем счётчики AUTO_INCREMENT (чтобы новые ID начинались с 1)
ALTER TABLE iiko_fields_common AUTO_INCREMENT = 1;
ALTER TABLE iiko_tags AUTO_INCREMENT = 1;
ALTER TABLE iiko_report_types AUTO_INCREMENT = 1;
-- Включаем проверку обратно
SET FOREIGN_KEY_CHECKS = 1;
""";
return db.getPool().query(sql).execute().mapEmpty();
}
}
@@ -14,19 +14,12 @@ import java.util.stream.Collectors;
public class IikoOlapClient {
private static final Logger log = LoggerFactory.getLogger(IikoOlapClient.class);
private static final Logger log = LoggerFactory.getLogger("[IikoOlapClient]");
private final WebClient webClient;
private final String iikoHost;
private final String iikoLogin;
private final String iikoPassHash;
public IikoOlapClient(Vertx vertx, String host, String login, String passHash, boolean https) {
this.webClient = WebClient.create(vertx);
this.iikoHost = (https ? "https://" : "http://") + host + (https ? ":443" : ":80");
this.iikoLogin = login;
this.iikoPassHash = passHash;
}
public IikoOlapClient(Vertx vertx, JsonObject rest) {
this.webClient = WebClient.create(vertx);
this.iikoHost = (rest.getBoolean("https") ? "https://" : "http://") + rest.getString("host") + (rest.getBoolean("https") ? ":443" : ":80");
@@ -36,7 +29,7 @@ public class IikoOlapClient {
private Future<String> authenticate() {
Promise<String> promise = Promise.promise();
String url = iikoHost + "/resto/api/auth"; //?login=" + iikoLogin + "&pass=" + iikoPassHash;
String url = iikoHost + "/resto/api/auth";
webClient.getAbs(url)
.addQueryParam("login", iikoLogin)
@@ -65,7 +58,6 @@ public class IikoOlapClient {
.addQueryParam("key", token)
.send()
.onSuccess(resp -> {
// log.info("Logout completed for token, status {}", resp.statusCode());
log.info(resp.bodyAsString());
promise.complete();
})
@@ -105,7 +97,6 @@ public class IikoOlapClient {
.onSuccess(resp -> {
if (resp.statusCode() == 200) {
JsonObject body = resp.bodyAsJsonObject();
// Если есть обёртка data, распаковываем
JsonObject data = body.containsKey("data") && body.getValue("data") instanceof JsonObject
? body.getJsonObject("data")
: body;
@@ -1,44 +1,30 @@
package su.xserver.iikocon.iiko;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.mysqlclient.MySQLConnectOptions;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import io.vertx.sqlclient.Tuple;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.service.DataBaseService;
import java.util.ArrayList;
import java.util.List;
public class IikoOlapColumnsImporter {
private static final Logger log = LoggerFactory.getLogger(IikoOlapColumnsImporter.class);
private final Pool dbPool;
private static final Logger log = LoggerFactory.getLogger("[IikoOlapColumnsImporter]");
private final DataBaseService db;
private final IikoOlapClient iikoOlapClient;
private static final List<String> REPORT_TYPES = List.of("SALES", "TRANSACTIONS", "DELIVERIES");
public IikoOlapColumnsImporter(Vertx vertx, String iikoServer, String iikoLogin, String iikoPassword, String dbHost, int dbPort, String dbName, String dbUser, String dbPassword) {
this.iikoOlapClient = new IikoOlapClient(vertx, iikoServer, iikoLogin, iikoPassword, true);
MySQLConnectOptions connectOptions = new MySQLConnectOptions()
.setHost(dbHost)
.setPort(dbPort)
.setDatabase(dbName)
.setUser(dbUser)
.setPassword(dbPassword)
.setCharset("utf8mb4");
PoolOptions poolOptions = new PoolOptions().setMaxSize(5);
this.dbPool = Pool.pool(vertx, connectOptions, poolOptions);
public IikoOlapColumnsImporter(IikoOlapClient iikoOlapClient, DataBaseService db) {
this.iikoOlapClient = iikoOlapClient;
this.db = db;
}
public Future<Void> fetchAndStoreAll() {
return createTablesIfNotExist()
.compose(v -> processAllReportTypesSequentially())
return processAllReportTypesSequentially()
.onSuccess(v -> log.info("All reports imported successfully"))
.onFailure(err -> log.error("Import failed: {}", err.getMessage()));
}
@@ -57,18 +43,11 @@ public class IikoOlapColumnsImporter {
.compose(columnsJson -> storeColumnsToDb(reportType, columnsJson));
}
// Запрос полей для конкретного reportType
private Future<JsonObject> fetchColumnsFromIiko(String reportType) {
Promise<JsonObject> promise = Promise.promise();
iikoOlapClient.handleGet("/resto/api/v2/reports/olap/columns", new JsonObject().put("reportType", reportType))
.onSuccess(promise::complete)
.onFailure(promise::fail);
return promise.future();
return iikoOlapClient.handleGet("/resto/api/v2/reports/olap/columns",
new JsonObject().put("reportType", reportType));
}
// ---------- Методы работы с БД (с префиксом iiko_) ----------
private Future<Void> storeColumnsToDb(String reportType, JsonObject columns) {
return getOrCreateReportType(reportType)
.compose(reportTypeId -> {
@@ -82,86 +61,81 @@ public class IikoOlapColumnsImporter {
}
private Future<Integer> getOrCreateReportType(String reportType) {
Promise<Integer> promise = Promise.promise();
String selectSql = "SELECT report_type_id FROM iiko_report_types WHERE name = ?";
dbPool.preparedQuery(selectSql)
.execute(Tuple.of(reportType))
.onComplete(ar -> {
if (ar.succeeded() && ar.result().size() > 0) {
promise.complete(ar.result().iterator().next().getInteger("report_type_id"));
} else if (ar.succeeded()) {
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(reportType))
.compose(rows -> {
if (rows.size() > 0) {
return Future.succeededFuture(rows.iterator().next().getInteger("report_type_id"));
} else {
String insertSql = "INSERT INTO iiko_report_types (name, description) VALUES (?, ?)";
dbPool.preparedQuery(insertSql)
return db.getPool().preparedQuery(insertSql)
.execute(Tuple.of(reportType, "OLAP report type: " + reportType))
.onComplete(insAr -> {
if (insAr.succeeded()) {
dbPool.preparedQuery(selectSql)
.execute(Tuple.of(reportType))
.onComplete(selAr -> {
if (selAr.succeeded() && selAr.result().size() > 0) {
promise.complete(selAr.result().iterator().next().getInteger("report_type_id"));
} else {
promise.fail("Cannot retrieve inserted report_type_id for " + reportType);
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(reportType))
.map(rows2 -> rows2.iterator().next().getInteger("report_type_id"))
);
}
});
} else {
promise.fail(insAr.cause());
}
});
} else {
promise.fail(ar.cause());
}
});
return promise.future();
}
/**
* Сохранить одно поле (без дублирования).
* Сначала получаем/создаём запись в iiko_fields_common,
* затем связываем её с report_type_id через iiko_report_type_fields,
* потом обрабатываем теги.
*/
private Future<Void> storeSingleField(int reportTypeId, String fieldKey, JsonObject fieldDef) {
// Нормализованный ключ (без точек)
String fieldKeyNormal = fieldKey.replace('.', '_');
String name = fieldDef.getString("name");
String originalType = fieldDef.getString("type");
String typeNormal = normalizeType(originalType);
boolean aggregationAllowed = fieldDef.getBoolean("aggregationAllowed", false);
boolean groupingAllowed = fieldDef.getBoolean("groupingAllowed", false);
boolean filteringAllowed = fieldDef.getBoolean("filteringAllowed", false);
JsonArray tagsArray = fieldDef.getJsonArray("tags", new JsonArray());
String insertFieldSql = """
INSERT INTO iiko_fields (
report_type_id, field_key, field_key_normal, name, type, type_normal,
aggregation_allowed, grouping_allowed, filtering_allowed
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
field_key_normal = VALUES(field_key_normal),
name = VALUES(name),
type_normal = VALUES(type_normal),
aggregation_allowed = VALUES(aggregation_allowed),
grouping_allowed = VALUES(grouping_allowed),
filtering_allowed = VALUES(filtering_allowed)
""";
return getOrCreateCommonField(fieldKey, name, originalType,
aggregationAllowed, groupingAllowed, filteringAllowed)
.compose(fieldId -> linkFieldToReportType(reportTypeId, fieldId)
.compose(v -> processTags(fieldId, tagsArray))
);
}
return dbPool.preparedQuery(insertFieldSql)
.execute(Tuple.of(
reportTypeId, fieldKey, fieldKeyNormal, name, originalType, typeNormal,
aggregationAllowed, groupingAllowed, filteringAllowed
))
.compose(ignored -> {
String selectFieldIdSql = "SELECT field_id FROM iiko_fields WHERE report_type_id = ? AND field_key = ?";
return dbPool.preparedQuery(selectFieldIdSql)
.execute(Tuple.of(reportTypeId, fieldKey))
/**
* Найти или создать поле в iiko_fields_common (по уникальному field_key).
*/
private Future<Integer> getOrCreateCommonField(String fieldKey, String name, String type,
boolean aggAllowed, boolean groupAllowed, boolean filterAllowed) {
String selectSql = "SELECT field_id FROM iiko_fields_common WHERE field_key = ?";
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(fieldKey))
.compose(rows -> {
if (rows.size() == 0) {
return Future.failedFuture("Field not found after upsert: " + fieldKey);
if (rows.size() > 0) {
return Future.succeededFuture(rows.iterator().next().getInteger("field_id"));
} else {
String insertSql = """
INSERT INTO iiko_fields_common
(field_key, name, type, aggregation_allowed, grouping_allowed, filtering_allowed)
VALUES (?, ?, ?, ?, ?, ?)
""";
return db.getPool().preparedQuery(insertSql)
.execute(Tuple.of(fieldKey, name, type, aggAllowed, groupAllowed, filterAllowed))
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(fieldKey))
.map(rows2 -> rows2.iterator().next().getInteger("field_id"))
);
}
int fieldId = rows.iterator().next().getInteger("field_id");
return processTags(fieldId, tagsArray);
});
});
}
/**
* Привязать поле к типу отчёта (если ещё не привязано).
*/
private Future<Void> linkFieldToReportType(int reportTypeId, int fieldId) {
String sql = "INSERT IGNORE INTO iiko_report_type_fields (report_type_id, field_id) VALUES (?, ?)";
return db.getPool().preparedQuery(sql).execute(Tuple.of(reportTypeId, fieldId)).mapEmpty();
}
/**
* Обработать теги поля (теги одинаковы для всех типов отчётов).
*/
private Future<Void> processTags(int fieldId, JsonArray tags) {
List<Future<Void>> tagFutures = new ArrayList<>();
for (Object tagObj : tags) {
@@ -173,103 +147,24 @@ public class IikoOlapColumnsImporter {
}
private Future<Integer> getOrCreateTag(String tagName) {
Promise<Integer> promise = Promise.promise();
String selectSql = "SELECT tag_id FROM iiko_tags WHERE tag_name = ?";
dbPool.preparedQuery(selectSql)
.execute(Tuple.of(tagName))
.onComplete(ar -> {
if (ar.succeeded() && ar.result().size() > 0) {
promise.complete(ar.result().iterator().next().getInteger("tag_id"));
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(tagName))
.compose(rows -> {
if (rows.size() > 0) {
return Future.succeededFuture(rows.iterator().next().getInteger("tag_id"));
} else {
String insertSql = "INSERT IGNORE INTO iiko_tags (tag_name) VALUES (?)";
dbPool.preparedQuery(insertSql)
.execute(Tuple.of(tagName))
.onComplete(insAr -> {
// После IGNORE всё равно выбираем ID (он мог уже существовать)
dbPool.preparedQuery(selectSql)
.execute(Tuple.of(tagName))
.onComplete(selAr -> {
if (selAr.succeeded() && selAr.result().size() > 0) {
promise.complete(selAr.result().iterator().next().getInteger("tag_id"));
} else {
promise.fail("Cannot retrieve tag_id for " + tagName);
return db.getPool().preparedQuery(insertSql).execute(Tuple.of(tagName))
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(tagName))
.map(rows2 -> rows2.iterator().next().getInteger("tag_id"))
);
}
});
});
}
});
return promise.future();
}
private Future<Void> linkFieldTag(int fieldId, int tagId) {
String sql = "INSERT IGNORE INTO iiko_field_tags (field_id, tag_id) VALUES (?, ?)";
return dbPool.preparedQuery(sql)
.execute(Tuple.of(fieldId, tagId))
.mapEmpty();
}
private Future<Void> createTablesIfNotExist() {
String createReportTypesTable = """
CREATE TABLE IF NOT EXISTS iiko_report_types (
report_type_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createFieldsTable = """
CREATE TABLE IF NOT EXISTS iiko_fields (
field_id INT AUTO_INCREMENT PRIMARY KEY,
report_type_id INT NOT NULL,
field_key VARCHAR(255) NOT NULL,
field_key_normal VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
type_normal VARCHAR(50) NOT NULL,
aggregation_allowed BOOLEAN NOT NULL DEFAULT 0,
grouping_allowed BOOLEAN NOT NULL DEFAULT 0,
filtering_allowed BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_fields_report_type_field_key (report_type_id, field_key),
FOREIGN KEY (report_type_id) REFERENCES iiko_report_types(report_type_id) ON DELETE RESTRICT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createTagsTable = """
CREATE TABLE IF NOT EXISTS iiko_tags (
tag_id INT AUTO_INCREMENT PRIMARY KEY,
tag_name VARCHAR(100) UNIQUE NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createFieldTagsTable = """
CREATE TABLE IF NOT EXISTS iiko_field_tags (
field_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (field_id, tag_id),
FOREIGN KEY (field_id) REFERENCES iiko_fields(field_id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES iiko_tags(tag_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createIdxFieldsReportType = "CREATE INDEX IF NOT EXISTS idx_fields_report_type ON iiko_fields(report_type_id)";
String createIdxFieldsName = "CREATE INDEX IF NOT EXISTS idx_fields_name ON iiko_fields(name)";
String createIdxFieldTagsTagId = "CREATE INDEX IF NOT EXISTS idx_field_tags_tag_id ON iiko_field_tags(tag_id)";
return dbPool.query(createReportTypesTable).execute()
.compose(ignored -> dbPool.query(createFieldsTable).execute())
.compose(ignored -> dbPool.query(createTagsTable).execute())
.compose(ignored -> dbPool.query(createFieldTagsTable).execute())
.compose(ignored -> dbPool.query(createIdxFieldsReportType).execute())
.compose(ignored -> dbPool.query(createIdxFieldsName).execute())
.compose(ignored -> dbPool.query(createIdxFieldTagsTagId).execute())
.mapEmpty();
}
private String normalizeType(String iikoType) {
if (iikoType == null) return "string";
return switch (iikoType) {
case "ENUM", "STRING", "ID", "ID_STRING" -> "string";
case "DATETIME" -> "datetime";
case "INTEGER", "DURATION_IN_SECONDS" -> "integer";
case "PERCENT", "AMOUNT", "MONEY" -> "decimal";
default -> "string";
};
return db.getPool().preparedQuery(sql).execute(Tuple.of(fieldId, tagId)).mapEmpty();
}
}

Some files were not shown because too many files have changed in this diff Show More