Compare commits
48 Commits
1c7e05f6a3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 16fc97ab6d | |||
| 98e3c95294 | |||
| 3d9fc71e7d | |||
| 98f854c1a3 | |||
| f3a0750891 | |||
| f43314b122 | |||
| 57b5a9fa12 | |||
| 1e7587e11b | |||
| debf1b165f | |||
| f3b407e1ed | |||
| 5382488a82 | |||
| 031757353d | |||
| 8f86dc5831 | |||
| 1531215b43 | |||
| fa0b2518af | |||
| 4b4486e3ef | |||
| 651d2a5d0c | |||
| a1bd5a2b5f | |||
| 71cae60b90 | |||
| 0b20b77690 | |||
| 1a5b10b129 | |||
| 59e283945c | |||
| 096fb1a3e2 | |||
| c108ad4a5a | |||
| 4e60a78fbd | |||
| 08368afbc5 | |||
| a851df0494 | |||
| b203d6b7d8 | |||
| 7ae323a0c5 | |||
| 8386be18ba | |||
| ec6c52c79d | |||
| 0e6103f138 | |||
| a406af54bd | |||
| 1ca4c90b88 | |||
| a61c527ef9 | |||
| f39d9ff11e | |||
| c801783779 | |||
| 50d4ea10c6 | |||
| 0836f8e9e9 | |||
| e7f135e8c1 | |||
| 664092f415 | |||
| 38cc75a688 | |||
| 7a60bb15fe | |||
| 43b57bdb0f | |||
| 05076eb367 | |||
| 316d06b1d2 | |||
| a68f02bab4 | |||
| aad6ba3747 |
@@ -185,3 +185,5 @@ nbdist/
|
||||
!.vscode/extensions.json
|
||||
/build/
|
||||
/logs/
|
||||
/src/main/resources/version.properties
|
||||
/src/main/resources/webroot/
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
# iiko-connector
|
||||
|
||||
* `Числовые` → `Агрегация`
|
||||
* `Категории` → `Группировка {ROW / COLUMN}`
|
||||
* `Фильтры` → `Фильтрация`
|
||||
|
||||
+109
-13
@@ -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
@@ -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
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080' // для разработки
|
||||
|
||||
@@ -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.
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.
BIN
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.
BIN
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.
BIN
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
Reference in New Issue
Block a user