Compare commits
60 Commits
9b12895199
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 1c7e05f6a3 | |||
| ff46a37956 | |||
| c47dad2af8 | |||
| 82a932dd2b | |||
| b9d1afad42 | |||
| 1d8a436106 | |||
| b7875bb623 | |||
| fc96a95335 | |||
| f16a830eb2 | |||
| f3e105bbc8 | |||
| ec0671c5e8 | |||
| fd3cbb019f | |||
| c47542bef3 | |||
| 0bca35d015 | |||
| 7cc1ff9555 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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}`
|
||||
* `Фильтры` → `Фильтрация`
|
||||
|
||||
119
build.gradle.kts
119
build.gradle.kts
@@ -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.0-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,25 @@ 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")
|
||||
|
||||
}
|
||||
|
||||
@@ -132,3 +144,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")
|
||||
}
|
||||
|
||||
@@ -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://phpmyadmin.dev.xserver.su/
|
||||
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,3 +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,6 +13,7 @@
|
||||
"axios": "^1.15.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-i18n": "^11.4.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -22,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": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 17 KiB |
@@ -19,3 +19,25 @@
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
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"]')
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta')
|
||||
meta.setAttribute('name', 'description')
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
meta.setAttribute('content', desc || '')
|
||||
}, { immediate: true })
|
||||
onMounted(() => {
|
||||
versionStore.fetchVersion()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,100 +1,196 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Sidebar -->
|
||||
<aside class="fixed inset-y-0 left-0 w-64 bg-white border-r border-gray-200">
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 bg-white border-r border-gray-200 transition-all duration-300 z-20"
|
||||
:class="sidebarCollapsed ? 'w-16' : 'w-64'"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center h-16 px-6 border-b border-gray-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Logo / Toggle Button -->
|
||||
<div class="flex items-center h-16 px-4 border-b border-gray-200" :class="sidebarCollapsed ? 'justify-center' : 'justify-between'">
|
||||
<div v-if="!sidebarCollapsed" class="flex items-center space-x-2">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gray-900">AdminPanel</span>
|
||||
<span class="text-xl font-bold text-gray-900">{{ settings.siteName }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleSidebar"
|
||||
class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
:class="sidebarCollapsed ? 'mx-auto' : ''"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="!sidebarCollapsed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||
<nav class="flex-1 px-2 py-6 space-y-1 overflow-y-auto">
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors group"
|
||||
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/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',
|
||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||
]"
|
||||
:title="sidebarCollapsed ? t('app.dashboard') : ''"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<!-- 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>
|
||||
Dashboard
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.dashboard') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="userStore.role === 'admin'"
|
||||
to="/users"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/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',
|
||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||
]"
|
||||
:title="sidebarCollapsed ? t('app.users') : ''"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<!-- 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>
|
||||
Users
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.users') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/restaurants"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/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',
|
||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||
]"
|
||||
:title="sidebarCollapsed ? t('app.restaurants') : ''"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-3" 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>
|
||||
Restaurants
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/settings"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/settings' }"
|
||||
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') : ''"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<!-- 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
|
||||
v-if="userStore.role === 'admin'"
|
||||
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',
|
||||
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>
|
||||
Settings
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="p-4 border-t border-gray-200">
|
||||
<!-- User Info (collapsed aware) -->
|
||||
<div v-if="!sidebarCollapsed" class="p-4 border-t border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white font-semibold">
|
||||
{{ userInitials }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ userName }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">Administrator</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ userStore.role === 'admin' ? t('app.administrator') : t('app.user') }}</p>
|
||||
</div>
|
||||
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 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="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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-2 border-t border-gray-200 flex justify-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white font-semibold text-sm">
|
||||
{{ 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>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="ml-64">
|
||||
<!-- Header -->
|
||||
<header class="bg-white border-b border-gray-200">
|
||||
<div class="flex items-center justify-between h-16 px-8">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">{{ pageTitle }}</h1>
|
||||
<main class="transition-all duration-300" :class="sidebarCollapsed ? 'ml-16' : 'ml-64'">
|
||||
<!-- Header (без заголовка) -->
|
||||
<header class="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div class="flex items-center justify-end h-16 px-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
:placeholder="t('app.search')"
|
||||
class="w-64 px-4 py-2 pl-10 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -103,12 +199,42 @@
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<button class="relative p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<button class="relative p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.notifications')">
|
||||
<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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
<!-- User actions -->
|
||||
<div class="flex items-center space-x-1 border-l pl-4 ml-2">
|
||||
<button @click="toggleLanguage" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.language')">
|
||||
<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="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
</button>
|
||||
<router-link to="/profile" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.profile')">
|
||||
<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="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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -118,32 +244,89 @@
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<!-- Notification Toast -->
|
||||
<Transition name="slide">
|
||||
<div v-if="notification.show" class="fixed bottom-4 right-4 z-50 flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
|
||||
<svg v-if="notification.type === 'success'" 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" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ notification.message }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { useI18n } from 'vue-i18n'
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
import { useVersionStore } from '@/stores/version'
|
||||
|
||||
const { notification, showNotification } = useNotification()
|
||||
const settings = useSettingsStore()
|
||||
const versionStore = useVersionStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userName = ref('Loading...')
|
||||
const userLogin = ref('')
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/me')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
userLogin.value = data.login
|
||||
userName.value = data.login // или можно сделать красивое отображение
|
||||
const userName = computed(() => userStore.login || 'User')
|
||||
const userInitials = computed(() => (userName.value[0] || 'U').toUpperCase())
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = 'admin_sidebar_collapsed'
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY)
|
||||
if (saved !== null) sidebarCollapsed.value = saved === 'true'
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarCollapsed.value))
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/logout', { method: 'POST' })
|
||||
userStore.clear()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
async function toggleLanguage() {
|
||||
const newLang = locale.value === 'en' ? 'ru' : 'en'
|
||||
if (userStore.id) {
|
||||
const ok = await userStore.updateProfile({ language: newLang })
|
||||
if (ok) {
|
||||
locale.value = newLang
|
||||
localStorage.setItem('locale', newLang)
|
||||
} else {
|
||||
showNotification('profile.updateError', 'error');
|
||||
// В случае ошибки всё равно меняем локаль, но не сохраняем в БД
|
||||
locale.value = newLang
|
||||
localStorage.setItem('locale', newLang)
|
||||
}
|
||||
} catch (e) {
|
||||
userName.value = 'User'
|
||||
} else {
|
||||
// Для неавторизованных просто сохраняем в localStorage
|
||||
locale.value = newLang
|
||||
localStorage.setItem('locale', newLang)
|
||||
}
|
||||
})
|
||||
|
||||
const userInitials = computed(() => {
|
||||
return (userName.value[0] || 'U').toUpperCase()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
43
frontend/src/composables/useNotification.ts
Normal file
43
frontend/src/composables/useNotification.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type NotificationType = 'success' | 'error'
|
||||
|
||||
interface Notification {
|
||||
show: boolean
|
||||
type: NotificationType
|
||||
message: string
|
||||
}
|
||||
|
||||
const notification = ref<Notification>({
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
})
|
||||
|
||||
let timeoutId: number | null = null
|
||||
|
||||
export function useNotification() {
|
||||
const { t } = useI18n()
|
||||
|
||||
const showNotification = (messageKey: string, type: NotificationType = 'success', params?: Record<string, any>) => {
|
||||
const message = params ? t(messageKey, params) : t(messageKey)
|
||||
|
||||
// Очищаем предыдущий таймер, чтобы уведомление не закрылось раньше времени
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
|
||||
notification.value = { show: true, type, message }
|
||||
timeoutId = window.setTimeout(() => {
|
||||
notification.value.show = false
|
||||
timeoutId = null
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
return {
|
||||
notification: readonly(notification),
|
||||
showNotification
|
||||
}
|
||||
}
|
||||
368
frontend/src/locales/en.json
Normal file
368
frontend/src/locales/en.json
Normal file
@@ -0,0 +1,368 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Admin Panel",
|
||||
"dashboard": "Dashboard",
|
||||
"database": "Data Base",
|
||||
"users": "Users",
|
||||
"restaurants": "Restaurants",
|
||||
"settings": "Settings",
|
||||
"profile": "Profile",
|
||||
"logout": "Logout",
|
||||
"language": "Language",
|
||||
"search": "Search...",
|
||||
"notifications": "Notifications",
|
||||
"administrator": "Administrator",
|
||||
"user": "User",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"reset": "Reset",
|
||||
"loading": "Loading...",
|
||||
"all": "all",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"common": {
|
||||
"id": "ID",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"login": "Login",
|
||||
"username": "Username",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"ip": "IP",
|
||||
"created": "Created",
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"passwordRequired": "Password is required",
|
||||
"saveChanges": "Save Changes",
|
||||
"confirmDelete": "Confirm Delete",
|
||||
"leavePasswordBlank": "Leave blank to keep current password",
|
||||
"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",
|
||||
"version": "Version",
|
||||
"versionFrom": "from"
|
||||
},
|
||||
"dashboard": {
|
||||
"totalUsers": "Total Users",
|
||||
"totalRestaurants": "Total Restaurants",
|
||||
"systemHealth": "System Health",
|
||||
"uptime": "Uptime",
|
||||
"vsLastMonth": "vs last month",
|
||||
"fromLastHour": "from last hour",
|
||||
"operational": "Operational",
|
||||
"down": "Down",
|
||||
"userActivity": "User Activity (Last 7 days)",
|
||||
"week": "Week",
|
||||
"month": "Month",
|
||||
"systemServices": "System Services",
|
||||
"recentUsers": "Recent Users",
|
||||
"recentRestaurants": "Recent Restaurants",
|
||||
"viewAll": "View all",
|
||||
"noUsers": "No users yet",
|
||||
"noRestaurants": "No restaurants yet",
|
||||
"new": "New",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"daysAgo": "{count} days ago",
|
||||
"loadError": "Failed to load dashboard data"
|
||||
},
|
||||
"users": {
|
||||
"pageName": "Users Management",
|
||||
"add": "Add User",
|
||||
"edit": "Edit User",
|
||||
"delete": "Delete User",
|
||||
"you": "(You)",
|
||||
"confirmDelete": "Delete User",
|
||||
"deleteConfirmation": "Are you sure you want to delete this user? This action cannot be undone.",
|
||||
"statusUpdated": "User status updated",
|
||||
"statusUpdateError": "Failed to update user status",
|
||||
"passwordRequired": "Password is required for new user",
|
||||
"createSuccess": "User created successfully",
|
||||
"createError": "Failed to create user",
|
||||
"updateSuccess": "User updated successfully",
|
||||
"updateError": "Failed to update user",
|
||||
"deleteSuccess": "User deleted",
|
||||
"deleteError": "Failed to delete user",
|
||||
"cannotChangeOwnRole": "You cannot change your own role",
|
||||
"noUsers": "No users found",
|
||||
"loadError": "Failed to load users",
|
||||
"loadCurrentError": "Failed to load current user info"
|
||||
},
|
||||
"restaurants": {
|
||||
"pageName": "Restaurants",
|
||||
"add": "Add Restaurant",
|
||||
"edit": "Edit Restaurant",
|
||||
"delete": "Delete Restaurant",
|
||||
"host": "Host",
|
||||
"https": "HTTPS",
|
||||
"useHttps": "Use HTTPS",
|
||||
"confirmDelete": "Delete Restaurant",
|
||||
"noRestaurants": "No restaurants found. Click \"Add Restaurant\" to create one.",
|
||||
"deleteConfirmation": "Are you sure you want to delete this restaurant? This action cannot be undone.",
|
||||
"check": "Check connection",
|
||||
"checkError": "Check failed: {error}",
|
||||
"loadError": "Failed to load restaurants",
|
||||
"createSuccess": "Restaurant created successfully",
|
||||
"updateSuccess": "Restaurant updated successfully",
|
||||
"deleteSuccess": "Restaurant deleted",
|
||||
"httpsUpdateSuccess": "HTTPS status updated",
|
||||
"httpsUpdateError": "Failed to update HTTPS",
|
||||
"passwordRequired": "Password is required for new restaurant",
|
||||
"checkNetworkError": "Network error while checking"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Application Settings",
|
||||
"save": "Save Changes",
|
||||
"reset": "Reset",
|
||||
"saved": "Settings saved successfully",
|
||||
"saveFailed": "Failed to save settings",
|
||||
"loadFailed": "Failed to load settings metadata",
|
||||
"enabled": "Enabled",
|
||||
"saveSuccess": "Settings saved successfully",
|
||||
"saveError": "Failed to save settings",
|
||||
"loadMetaError": "Failed to load settings metadata"
|
||||
},
|
||||
"profile": {
|
||||
"title": "My Profile",
|
||||
"subtitle": "Manage your account settings",
|
||||
"username": "Username",
|
||||
"email": "Email Address",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm New Password",
|
||||
"save": "Save Changes",
|
||||
"reset": "Reset",
|
||||
"role": "Role",
|
||||
"passwordsMismatch": "Passwords do not match",
|
||||
"updateSuccess": "Profile updated successfully",
|
||||
"updateError": "Failed to update profile"
|
||||
},
|
||||
"login": {
|
||||
"title": "Welcome Back",
|
||||
"subtitle": "Sign in to your account",
|
||||
"username": "Username or Email",
|
||||
"remember": "Remember me",
|
||||
"signin": "Sign In",
|
||||
"createAccount": "Create account",
|
||||
"invalidCredentials": "Invalid username or password",
|
||||
"networkError": "Network error. Please try again."
|
||||
},
|
||||
"register": {
|
||||
"title": "Create Account",
|
||||
"subtitle": "Register and wait for admin approval",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"register": "Register",
|
||||
"success": "Account created! Wait for admin activation.",
|
||||
"failed": "Registration failed",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"networkError": "Network error"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Setup Admin Account",
|
||||
"subtitle": "Create your administrator account",
|
||||
"step1": "Account Details",
|
||||
"step2": "Complete",
|
||||
"createAccount": "Create Account",
|
||||
"passwordStrength": "Password strength",
|
||||
"veryWeak": "Very Weak",
|
||||
"weak": "Weak",
|
||||
"fair": "Fair",
|
||||
"good": "Good",
|
||||
"strong": "Strong",
|
||||
"validationUsernameMin": "Username must be at least 3 characters",
|
||||
"validationEmailInvalid": "Please enter a valid email address",
|
||||
"validationPasswordMin": "Password must be at least 6 characters",
|
||||
"createFailed": "Failed to create account"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Oops! Page not found",
|
||||
"message": "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.",
|
||||
"goToDashboard": "Go to Dashboard",
|
||||
"signIn": "Sign In"
|
||||
},
|
||||
"validation": {
|
||||
"required": "This field is required",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
368
frontend/src/locales/ru.json
Normal file
368
frontend/src/locales/ru.json
Normal file
@@ -0,0 +1,368 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Панель администратора",
|
||||
"dashboard": "Панель управления",
|
||||
"database": "База данных",
|
||||
"users": "Пользователи",
|
||||
"restaurants": "Рестораны",
|
||||
"settings": "Настройки",
|
||||
"profile": "Профиль",
|
||||
"logout": "Выйти",
|
||||
"language": "Язык",
|
||||
"search": "Поиск...",
|
||||
"notifications": "Уведомления",
|
||||
"administrator": "Администратор",
|
||||
"user": "Пользователь",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"add": "Добавить",
|
||||
"reset": "Сбросить",
|
||||
"loading": "Загрузка...",
|
||||
"all": "все",
|
||||
"confirm": "Подтвердить"
|
||||
},
|
||||
"common": {
|
||||
"id": "ID",
|
||||
"name": "Название",
|
||||
"email": "Email",
|
||||
"password": "Пароль",
|
||||
"login": "Логин",
|
||||
"username": "Имя пользователя",
|
||||
"role": "Роль",
|
||||
"status": "Статус",
|
||||
"ip": "IP",
|
||||
"created": "Создан",
|
||||
"actions": "Действия",
|
||||
"active": "Активен",
|
||||
"inactive": "Неактивен",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"passwordRequired": "Пароль обязателен",
|
||||
"saveChanges": "Сохранить изменения",
|
||||
"confirmDelete": "Подтверждение удаления",
|
||||
"leavePasswordBlank": "Оставьте пустым, чтобы оставить текущий пароль",
|
||||
"deleteConfirmation": "Вы уверены, что хотите удалить этот элемент? Это действие необратимо.",
|
||||
"operationSuccess": "Операция выполнена успешно",
|
||||
"operationFailed": "Операция не удалась",
|
||||
"networkError": "Ошибка сети",
|
||||
"version": "Версия",
|
||||
"versionFrom": "от"
|
||||
},
|
||||
"dashboard": {
|
||||
"totalUsers": "Всего пользователей",
|
||||
"totalRestaurants": "Всего ресторанов",
|
||||
"systemHealth": "Здоровье системы",
|
||||
"uptime": "Время работы",
|
||||
"vsLastMonth": "по сравнению с прошлым месяцем",
|
||||
"fromLastHour": "за последний час",
|
||||
"operational": "Работает",
|
||||
"down": "Недоступен",
|
||||
"userActivity": "Активность пользователей (последние 7 дней)",
|
||||
"week": "Неделя",
|
||||
"month": "Месяц",
|
||||
"systemServices": "Системные сервисы",
|
||||
"recentUsers": "Недавние пользователи",
|
||||
"recentRestaurants": "Недавние рестораны",
|
||||
"viewAll": "Все",
|
||||
"noUsers": "Пока нет пользователей",
|
||||
"noRestaurants": "Пока нет ресторанов",
|
||||
"new": "Новый",
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера",
|
||||
"daysAgo": "дн. назад",
|
||||
"loadError": "Ошибка загрузки данных дашборда"
|
||||
},
|
||||
"users": {
|
||||
"pageName": "Управление пользователями",
|
||||
"add": "Добавить пользователя",
|
||||
"edit": "Редактировать пользователя",
|
||||
"delete": "Удалить пользователя",
|
||||
"you": "(Вы)",
|
||||
"confirmDelete": "Удалить пользователя",
|
||||
"deleteConfirmation": "Вы уверены, что хотите удалить этого пользователя? Это действие необратимо.",
|
||||
"statusUpdated": "Статус пользователя обновлён",
|
||||
"statusUpdateError": "Не удалось обновить статус",
|
||||
"passwordRequired": "Пароль обязателен для нового пользователя",
|
||||
"createSuccess": "Пользователь создан",
|
||||
"createError": "Ошибка создания пользователя",
|
||||
"updateSuccess": "Пользователь обновлён",
|
||||
"updateError": "Ошибка обновления пользователя",
|
||||
"deleteSuccess": "Пользователь удалён",
|
||||
"deleteError": "Ошибка удаления пользователя",
|
||||
"cannotChangeOwnRole": "Вы не можете изменить свою роль",
|
||||
"noUsers": "Пользователи не найдены",
|
||||
"loadError": "Ошибка загрузки списка пользователей",
|
||||
"loadCurrentError": "Ошибка загрузки информации о текущем пользователе"
|
||||
},
|
||||
"restaurants": {
|
||||
"pageName": "Рестораны",
|
||||
"add": "Добавить ресторан",
|
||||
"edit": "Редактировать ресторан",
|
||||
"delete": "Удалить ресторан",
|
||||
"host": "Хост",
|
||||
"https": "HTTPS",
|
||||
"useHttps": "Использовать HTTPS",
|
||||
"confirmDelete": "Удалить ресторан",
|
||||
"noRestaurants": "Ресторанов не найдено. Нажмите \"Добавить ресторан\", чтобы создать его.",
|
||||
"deleteConfirmation": "Вы уверены, что хотите удалить этот ресторан? Это действие необратимо.",
|
||||
"check": "Проверить подключение",
|
||||
"checkError": "Ошибка проверки: {error}",
|
||||
"loadError": "Ошибка загрузки списка ресторанов",
|
||||
"createSuccess": "Ресторан успешно создан",
|
||||
"updateSuccess": "Ресторан успешно обновлён",
|
||||
"deleteSuccess": "Ресторан удалён",
|
||||
"httpsUpdateSuccess": "Статус HTTPS обновлён",
|
||||
"httpsUpdateError": "Не удалось обновить HTTPS",
|
||||
"passwordRequired": "Пароль обязателен для нового ресторана",
|
||||
"checkNetworkError": "Ошибка сети при проверке"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки приложения",
|
||||
"save": "Сохранить изменения",
|
||||
"reset": "Сбросить",
|
||||
"saved": "Настройки успешно сохранены",
|
||||
"saveFailed": "Не удалось сохранить настройки",
|
||||
"loadFailed": "Не удалось загрузить метаданные настроек",
|
||||
"enabled": "Включено",
|
||||
"saveSuccess": "Настройки сохранены",
|
||||
"saveError": "Ошибка сохранения настроек",
|
||||
"loadMetaError": "Ошибка загрузки метаданных"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Мой профиль",
|
||||
"subtitle": "Управление настройками аккаунта",
|
||||
"username": "Имя пользователя",
|
||||
"email": "Email",
|
||||
"newPassword": "Новый пароль",
|
||||
"confirmPassword": "Подтверждение нового пароля",
|
||||
"save": "Сохранить изменения",
|
||||
"reset": "Сбросить",
|
||||
"role": "Роль",
|
||||
"passwordsMismatch": "Пароли не совпадают",
|
||||
"updateSuccess": "Профиль обновлён",
|
||||
"updateError": "Ошибка обновления профиля"
|
||||
},
|
||||
"login": {
|
||||
"title": "С возвращением",
|
||||
"subtitle": "Войдите в свой аккаунт",
|
||||
"username": "Имя пользователя или Email",
|
||||
"remember": "Запомнить меня",
|
||||
"signin": "Войти",
|
||||
"createAccount": "Создать аккаунт",
|
||||
"invalidCredentials": "Неверное имя пользователя или пароль",
|
||||
"networkError": "Ошибка сети. Попробуйте еще раз."
|
||||
},
|
||||
"register": {
|
||||
"title": "Создать аккаунт",
|
||||
"subtitle": "Зарегистрируйтесь и ожидайте одобрения администратора",
|
||||
"username": "Имя пользователя",
|
||||
"email": "Email",
|
||||
"password": "Пароль",
|
||||
"register": "Зарегистрироваться",
|
||||
"success": "Аккаунт создан! Ожидайте активации администратором.",
|
||||
"failed": "Ошибка регистрации",
|
||||
"alreadyHaveAccount": "Уже есть аккаунт?",
|
||||
"networkError": "Ошибка сети"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Настройка учетной записи администратора",
|
||||
"subtitle": "Создайте учетную запись администратора",
|
||||
"step1": "Данные аккаунта",
|
||||
"step2": "Завершение",
|
||||
"createAccount": "Создать аккаунт",
|
||||
"passwordStrength": "Сложность пароля",
|
||||
"veryWeak": "Очень слабый",
|
||||
"weak": "Слабый",
|
||||
"fair": "Средний",
|
||||
"good": "Хороший",
|
||||
"strong": "Сильный",
|
||||
"validationUsernameMin": "Имя пользователя должно содержать не менее 3 символов",
|
||||
"validationEmailInvalid": "Введите корректный email адрес",
|
||||
"validationPasswordMin": "Пароль должен содержать не менее 6 символов",
|
||||
"createFailed": "Не удалось создать аккаунт"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Упс! Страница не найдена",
|
||||
"message": "Возможно, страница была удалена, переименована или временно недоступна.",
|
||||
"goToDashboard": "На главную",
|
||||
"signIn": "Войти"
|
||||
},
|
||||
"validation": {
|
||||
"required": "Это поле обязательно",
|
||||
"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": "Не удалось удалить подключение к БД."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,54 @@
|
||||
// src/main.ts
|
||||
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'
|
||||
|
||||
// Функция определения языка браузера
|
||||
function getBrowserLocale(): string {
|
||||
const browserLang = navigator.language.split('-')[0]
|
||||
return browserLang === 'ru' ? 'ru' : 'en'
|
||||
}
|
||||
|
||||
// Получаем сохраненный язык из localStorage (для неавторизованных)
|
||||
const storedLocale = localStorage.getItem('locale')
|
||||
const initialLocale = storedLocale || getBrowserLocale()
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages: { en, ru }
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
app.use(i18n)
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Загружаем настройки и профиль
|
||||
Promise.all([
|
||||
settingsStore.loadSettings(),
|
||||
userStore.fetchProfile().catch(() => {})
|
||||
]).then(() => {
|
||||
// Если пользователь авторизован – используем язык из профиля
|
||||
if (userStore.id && userStore.language) {
|
||||
i18n.global.locale.value = userStore.language
|
||||
// Сохраняем в localStorage для синхронизации (опционально)
|
||||
localStorage.setItem('locale', userStore.language)
|
||||
} else {
|
||||
// Для неавторизованных – сохраняем текущий язык в localStorage
|
||||
localStorage.setItem('locale', i18n.global.locale.value)
|
||||
}
|
||||
app.mount('#app')
|
||||
})
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
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 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' } },
|
||||
{ path: '/register', component: Register, meta: { title: 'Register' } },
|
||||
{ path: '/setup', component: Setup, meta: { title: 'Setup' } },
|
||||
{
|
||||
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'
|
||||
@@ -24,7 +44,7 @@ const routes = [
|
||||
{
|
||||
path: '/users',
|
||||
component: Users,
|
||||
meta: { requiresAuth: true, title: 'Users' }
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' }
|
||||
},
|
||||
{
|
||||
path: '/restaurants',
|
||||
@@ -32,66 +52,86 @@ const routes = [
|
||||
meta: { requiresAuth: true, title: 'Restaurants' }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: AdminSettings,
|
||||
meta: { requiresAuth: true, title: 'Settings' }
|
||||
path: '/olap/columns',
|
||||
component: OlapColumns,
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Olap Columns' }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: NotFound,
|
||||
meta: { title: 'Page Not Found', requiresAuth: false }
|
||||
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
|
||||
})
|
||||
const router = createRouter({ history: createWebHistory(), routes })
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// Update page title
|
||||
document.title = `${to.meta.title || 'Admin Panel'} | AdminPanel`
|
||||
const settings = useSettingsStore()
|
||||
if (!settings.siteName) await settings.loadSettings()
|
||||
|
||||
// Check if setup is needed
|
||||
document.title = to.meta.title ? `${to.meta.title} | ${settings.siteName}` : settings.siteName
|
||||
|
||||
// Проверка необходимости установки (setup)
|
||||
try {
|
||||
const statusRes = await fetch('/api/status')
|
||||
const status = await statusRes.json()
|
||||
|
||||
if (status.needsSetup && to.path !== '/setup') {
|
||||
next('/setup')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check status', e)
|
||||
} catch (e) { console.error('Failed to check status', e) }
|
||||
|
||||
const userStore = useUserStore()
|
||||
// Если профиль ещё не загружен – загружаем
|
||||
if (userStore.role === '') {
|
||||
await userStore.fetchProfile()
|
||||
}
|
||||
|
||||
if (to.path === '/login') {
|
||||
try {
|
||||
const meRes = await fetch('/api/admin/me');
|
||||
if (meRes.ok) {
|
||||
next('/dashboard');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// игнорируем ошибку, продолжаем
|
||||
}
|
||||
// Если уже залогинены и пытаемся зайти на login/register – редирект на дашборд
|
||||
if (userStore.id && (to.path === '/login' || to.path === '/register')) {
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
// Проверка доступности регистрации
|
||||
if (to.path === '/register' && !settings.enableRegistration) {
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
|
||||
|
||||
if (requiresAuth) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/me')
|
||||
if (!res.ok) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
} catch {
|
||||
next('/login')
|
||||
}
|
||||
if (requiresAuth && !userStore.id) {
|
||||
next('/login')
|
||||
} else if (requiresAdmin && userStore.role !== 'admin') {
|
||||
next('/dashboard')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
24
frontend/src/stores/settings.ts
Normal file
24
frontend/src/stores/settings.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const siteName = ref('Admin Panel')
|
||||
const siteDescription = ref('')
|
||||
const enableRegistration = ref(true)
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
siteName.value = data.site_name || 'Admin Panel'
|
||||
siteDescription.value = data.site_description || ''
|
||||
enableRegistration.value = data.enable_registration !== 'false'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings', e)
|
||||
}
|
||||
}
|
||||
|
||||
return { siteName, siteDescription, enableRegistration, loadSettings }
|
||||
})
|
||||
52
frontend/src/stores/user.ts
Normal file
52
frontend/src/stores/user.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const id = ref<number | null>(null)
|
||||
const login = ref('')
|
||||
const email = ref('')
|
||||
const role = ref('')
|
||||
const language = ref('en')
|
||||
|
||||
async function fetchProfile() {
|
||||
try {
|
||||
const res = await fetch('/api/profile')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
id.value = data.id
|
||||
login.value = data.login
|
||||
email.value = data.email
|
||||
role.value = data.role
|
||||
language.value = data.language || 'en'
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load profile', e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function updateProfile(updates: { email?: string; password?: string; language?: string }) {
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
if (res.ok) {
|
||||
if (updates.language) language.value = updates.language
|
||||
if (updates.email) email.value = updates.email
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function clear() {
|
||||
id.value = null
|
||||
login.value = ''
|
||||
email.value = ''
|
||||
role.value = ''
|
||||
language.value = 'en'
|
||||
}
|
||||
|
||||
return { id, login, email, role, language, fetchProfile, updateProfile, clear }
|
||||
})
|
||||
62
frontend/src/stores/version.ts
Normal file
62
frontend/src/stores/version.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface BuildVersion {
|
||||
version: string
|
||||
commitHash: string
|
||||
buildTime: string // ISO строка, например "2025-04-03T12:34:56Z"
|
||||
}
|
||||
|
||||
export const useVersionStore = defineStore('version', () => {
|
||||
const version = ref<BuildVersion | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchVersion() {
|
||||
if (version.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/build-info')
|
||||
if (!res.ok) throw new Error('Failed to fetch version')
|
||||
const data = await res.json()
|
||||
version.value = {
|
||||
version: data.version || '0.0.0',
|
||||
commitHash: data.commitHash || 'unknown',
|
||||
buildTime: data.buildTime || ''
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
error.value = err.message
|
||||
version.value = { version: 'dev', commitHash: 'unknown', buildTime: '' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Отформатированная дата сборки (только дата, без времени)
|
||||
const buildDateFormatted = computed(() => {
|
||||
if (!version.value?.buildTime) return ''
|
||||
const date = new Date(version.value.buildTime)
|
||||
if (isNaN(date.getTime())) return ''
|
||||
// Формат YYYY-MM-DD (универсальный, без локализации)
|
||||
return date.toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
// Полная строка версии: "Версия: 1.2.3 (build abc1234 от 2025-04-03)"
|
||||
// Принимает функцию перевода для слова "от"/"from"
|
||||
const getFormattedVersion = (t: (key: string) => string) => {
|
||||
if (!version.value) return t('common.version') + '...'
|
||||
const { version: ver, commitHash } = version.value
|
||||
const datePart = buildDateFormatted.value ? ` ${t('common.versionFrom')} ${buildDateFormatted.value}` : ''
|
||||
return `${t('common.version')}: ${ver} (build ${commitHash}${datePart})`
|
||||
}
|
||||
|
||||
const getShortVersion = () => {
|
||||
if (!version.value) return '...'
|
||||
const { version: ver, commitHash } = version.value
|
||||
const datePart = buildDateFormatted.value ? ` ${buildDateFormatted.value}` : ''
|
||||
return `${ver} (build ${commitHash}${datePart})`
|
||||
}
|
||||
|
||||
return { version, loading, error, fetchVersion, buildDateFormatted, getFormattedVersion, getShortVersion }
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="card">
|
||||
<h1 class="text-2xl font-bold mb-6">Application Settings</h1>
|
||||
<h1 class="text-2xl font-bold mb-6">{{ t('settings.title') }}</h1>
|
||||
<form @submit.prevent="saveSettings" class="space-y-6 max-w-2xl">
|
||||
<div v-for="field in meta" :key="field.key" class="border-b border-gray-200 pb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
@@ -54,22 +54,22 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" @click="loadData" class="btn-secondary">Reset</button>
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
<button type="button" @click="loadData" class="btn-secondary">{{ t('settings.reset') }}</button>
|
||||
<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'
|
||||
|
||||
const { showNotification } = useNotification()
|
||||
const { t } = useI18n()
|
||||
interface FieldMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -82,24 +82,22 @@ 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 {
|
||||
showMessage('Failed to load settings metadata', 'bg-red-50 text-red-800');
|
||||
showNotification('settings.loadMetaError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
showMessage('Failed to load settings values', 'bg-red-50 text-red-800');
|
||||
showNotification('settings.loadMetaError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,22 +113,14 @@ async function saveSettings() {
|
||||
body: JSON.stringify(values.value),
|
||||
});
|
||||
if (res.ok) {
|
||||
showMessage('Settings saved successfully', 'bg-green-50 text-green-800');
|
||||
showNotification('settings.saveSuccess', 'success');
|
||||
} else {
|
||||
showMessage('Failed to save settings', 'bg-red-50 text-red-800');
|
||||
showNotification('settings.saveError', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('Network error', 'bg-red-50 text-red-800');
|
||||
showNotification('common.networkError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(text: string, cssClass: string) {
|
||||
message.value = text;
|
||||
messageClass.value = cssClass;
|
||||
setTimeout(() => {
|
||||
message.value = '';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
@@ -2,68 +2,71 @@
|
||||
<AppLayout>
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="card animate-fade-in">
|
||||
<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">Total Users</p>
|
||||
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.totalUsers') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalUsers }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<div class="w-12 h-12 bg-primary-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center text-sm">
|
||||
<span class="text-green-600 font-medium">↑ 12%</span>
|
||||
<span class="text-gray-500 ml-2">from last month</span>
|
||||
<span class="text-green-600 font-medium">↑ {{ userGrowth }}%</span>
|
||||
<span class="text-gray-500 ml-2">{{ t('dashboard.vsLastMonth') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card animate-fade-in" style="animation-delay: 0.1s">
|
||||
<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">Active Sessions</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-lg flex items-center justify-center">
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center text-sm">
|
||||
<span class="text-green-600 font-medium">↑ 5%</span>
|
||||
<span class="text-gray-500 ml-2">from last hour</span>
|
||||
<span class="text-green-600 font-medium">↑ {{ sessionGrowth }}%</span>
|
||||
<span class="text-gray-500 ml-2">{{ t('dashboard.fromLastHour') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card animate-fade-in" style="animation-delay: 0.2s">
|
||||
<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">System Health</p>
|
||||
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.systemHealth') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.systemHealth }}%</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="h-2 bg-gray-200 rounded-full">
|
||||
<div class="h-2 bg-blue-600 rounded-full" :style="{ width: `${stats.systemHealth}%` }"></div>
|
||||
<div class="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-blue-600 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${stats.systemHealth}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card animate-fade-in" style="animation-delay: 0.3s">
|
||||
<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">Uptime</p>
|
||||
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.uptime') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.uptime }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -72,16 +75,58 @@
|
||||
<div class="mt-4 flex items-center text-sm">
|
||||
<div class="flex items-center text-green-600">
|
||||
<div class="w-2 h-2 bg-green-600 rounded-full mr-2"></div>
|
||||
Operational
|
||||
{{ t('dashboard.operational') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<!-- Charts & Activity -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- User Activity Chart -->
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ t('dashboard.userActivity') }}</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="activityPeriod = 'week'" class="text-xs px-2 py-1 rounded" :class="activityPeriod === 'week' ? 'bg-primary-100 text-primary-700' : 'text-gray-500'">{{ t('dashboard.week') }}</button>
|
||||
<button @click="activityPeriod = 'month'" class="text-xs px-2 py-1 rounded" :class="activityPeriod === 'month' ? 'bg-primary-100 text-primary-700' : 'text-gray-500'">{{ t('dashboard.month') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end space-x-2 h-48">
|
||||
<div v-for="(value, index) in activityData" :key="index" class="flex-1 flex flex-col items-center">
|
||||
<div class="w-full bg-primary-100 rounded-t-lg transition-all duration-500" :style="{ height: `${value}%`, minHeight: '4px' }"></div>
|
||||
<span class="text-xs text-gray-500 mt-2">{{ activityLabels[index] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Services Status -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">{{ t('dashboard.systemServices') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="service in systemServices" :key="service.name" class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div :class="['w-2 h-2 rounded-full', service.status === 'up' ? 'bg-green-500' : 'bg-red-500']"></div>
|
||||
<span class="text-gray-700 font-medium">{{ service.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm text-gray-500">{{ service.latency }}ms</span>
|
||||
<span class="text-xs px-2 py-1 rounded-full" :class="service.status === 'up' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'">
|
||||
{{ service.status === 'up' ? t('dashboard.operational') : t('dashboard.down') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Users & Restaurants -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Recent Users</h3>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ t('dashboard.recentUsers') }}</h3>
|
||||
<router-link to="/users" class="text-sm text-primary-600 hover:text-primary-700">{{ t('dashboard.viewAll') }} →</router-link>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="user in recentUsers" :key="user.id" class="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -93,21 +138,35 @@
|
||||
<p class="text-sm text-gray-500">{{ formatDate(user.created) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">New</span>
|
||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">{{ t('dashboard.new') }}</span>
|
||||
</div>
|
||||
<div v-if="recentUsers.length === 0" class="text-center text-gray-500 py-8">{{ t('dashboard.noUsers') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">System Status</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="service in systemServices" :key="service.name" class="flex items-center justify-between">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ t('dashboard.recentRestaurants') }}</h3>
|
||||
<router-link to="/restaurants" class="text-sm text-primary-600 hover:text-primary-700">{{ t('dashboard.viewAll') }} →</router-link>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="rest in recentRestaurants" :key="rest.id" class="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div :class="['w-2 h-2 rounded-full', service.status === 'up' ? 'bg-green-500' : 'bg-red-500']"></div>
|
||||
<span class="text-gray-700">{{ service.name }}</span>
|
||||
<div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-orange-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 class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-500">{{ formatDate(rest.created) }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">{{ service.latency }}ms</span>
|
||||
</div>
|
||||
<div v-if="recentRestaurants.length === 0" class="text-center text-gray-500 py-8">{{ t('dashboard.noRestaurants') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,88 +174,76 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import AppLayout from '../components/Layout/AppLayout.vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
|
||||
const stats = ref({ totalUsers: 0, activeSessions: 0, systemHealth: 100, uptime: '99.9%' })
|
||||
const { showNotification } = useNotification()
|
||||
const stats = ref({ totalUsers: 0, totalRestaurants: 0, systemHealth: 100, uptime: '99.9%' });
|
||||
const userGrowth = ref(12);
|
||||
const sessionGrowth = ref(5);
|
||||
const recentUsers = ref([]);
|
||||
const recentRestaurants = ref([]);
|
||||
const systemServices = ref([]);
|
||||
const activityPeriod = ref('week');
|
||||
const activityData = ref([65, 78, 82, 71, 88, 94, 72]);
|
||||
const activityLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
async function loadStats() {
|
||||
let interval: number;
|
||||
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const [usersRes, sessionsRes, healthRes] = await Promise.all([
|
||||
const [usersRes, healthRes, restaurantsRes] = await Promise.all([
|
||||
fetch('/api/admin/users'),
|
||||
fetch('/api/admin/active-sessions'),
|
||||
fetch('/api/health')
|
||||
])
|
||||
const users = await usersRes.json()
|
||||
const sessions = await sessionsRes.json()
|
||||
const health = await healthRes.json()
|
||||
fetch('/api/health'),
|
||||
fetch('/api/admin/restaurants')
|
||||
]);
|
||||
|
||||
stats.value.totalUsers = users.length
|
||||
stats.value.activeSessions = sessions.count || 0
|
||||
const users = await usersRes.json();
|
||||
const health = await healthRes.json();
|
||||
const restaurants = await restaurantsRes.json();
|
||||
|
||||
const upCount = health.checks?.filter(c => c.status === 'UP').length || 0
|
||||
const total = health.checks?.length || 1
|
||||
stats.value.systemHealth = Math.round((upCount / total) * 100)
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
stats.value.totalUsers = users.length;
|
||||
stats.value.totalRestaurants = restaurants.length;
|
||||
recentUsers.value = users.slice(-5).reverse();
|
||||
recentRestaurants.value = restaurants.slice(-5).reverse();
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
const interval = setInterval(loadStats, 5000)
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
})
|
||||
const upCount = health.checks?.filter((c: any) => c.status === 'UP').length || 0;
|
||||
const total = health.checks?.length || 1;
|
||||
stats.value.systemHealth = Math.round((upCount / total) * 100);
|
||||
|
||||
const recentUsers = ref([])
|
||||
const systemServices = ref([])
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const res = await fetch('/api/health')
|
||||
const data = await res.json()
|
||||
if (data.checks) {
|
||||
systemServices.value = data.checks.map(check => ({
|
||||
if (health.checks) {
|
||||
systemServices.value = health.checks.map((check: any) => ({
|
||||
name: check.data?.name || check.id,
|
||||
status: check.status.toLowerCase(),
|
||||
latency: check.data?.latency_ms || 0
|
||||
}))
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Health check failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
let interval: number
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
await loadHealth()
|
||||
interval = window.setInterval(loadHealth, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) clearInterval(interval)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users')
|
||||
const users = await res.json()
|
||||
stats.value.totalUsers = users.length
|
||||
recentUsers.value = users.slice(-5).reverse()
|
||||
} catch (e) {
|
||||
console.error('Failed to load data', e)
|
||||
showNotification('dashboard.loadError', 'error');
|
||||
console.error('Failed to load dashboard data', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return t('dashboard.today');
|
||||
if (diffDays === 1) return t('dashboard.yesterday');
|
||||
if (diffDays < 7) return `${diffDays} ${t('dashboard.daysAgo')}`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboardData();
|
||||
interval = window.setInterval(loadDashboardData, 10000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
384
frontend/src/views/DbConnections.vue
Normal file
384
frontend/src/views/DbConnections.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('dbConnections.pageName') }}</h1>
|
||||
<button @click="openModal('create')" class="btn-primary flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('dbConnections.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.type') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.host') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.port') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.database') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.user') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr v-for="conn in connections" :key="conn.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ conn.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ conn.name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span :class="getTypeBadgeClass(conn.type)" class="px-2 py-1 rounded-full text-xs font-medium">
|
||||
{{ getTypeLabel(conn.type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.host }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.port }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.database }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.user }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(conn.created) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-3">
|
||||
<button @click="testConnection(conn)" :disabled="conn.testing" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50" :title="t('dbConnections.test')">
|
||||
<svg v-if="!conn.testing" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="openModal('edit', conn)" class="text-blue-600 hover:text-blue-800 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(conn.id)" class="text-red-600 hover:text-red-800 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span v-if="conn.testResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
|
||||
{{ conn.testResult }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="connections.length === 0">
|
||||
<td colspan="9" class="px-6 py-12 text-center text-gray-500">{{ t('dbConnections.noConnections') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно создания/редактирования -->
|
||||
<Transition name="fade">
|
||||
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
|
||||
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitConnection" class="p-6 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }} *</label>
|
||||
<input v-model="form.name" type="text" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.type') }} *</label>
|
||||
<select v-model="form.type" required class="input-field">
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="clickhouse">ClickHouse</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.host') }} *</label>
|
||||
<input v-model="form.host" type="text" required class="input-field" placeholder="localhost or IP" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.port') }} *</label>
|
||||
<input v-model="form.port" type="number" required class="input-field" placeholder="3306, 5432, 8123..." />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.database') }} *</label>
|
||||
<input v-model="form.database" type="text" required class="input-field" placeholder="database name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.user') }} *</label>
|
||||
<input v-model="form.user" type="text" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.password') }}</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
:required="modalMode === 'create'"
|
||||
type="password"
|
||||
class="input-field"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button type="button" @click="closeModal" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Модальное окно подтверждения удаления -->
|
||||
<Transition name="fade">
|
||||
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||
<div class="p-6 text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('dbConnections.delete') }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">{{ t('dbConnections.deleteConfirmation') }}</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button @click="deleteConnection(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useNotification } from '@/composables/useNotification';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
type Connection = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'mysql' | 'postgres' | 'clickhouse';
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
created: string;
|
||||
testing?: boolean;
|
||||
testResult?: string | null;
|
||||
};
|
||||
|
||||
const connections = ref<Connection[]>([]);
|
||||
const modalOpen = ref(false);
|
||||
const modalMode = ref<'create' | 'edit'>('create');
|
||||
const form = ref({
|
||||
id: null as number | null,
|
||||
name: '',
|
||||
type: 'mysql' as 'mysql' | 'postgres' | 'clickhouse',
|
||||
host: '',
|
||||
port: 3306,
|
||||
database: '',
|
||||
user: '',
|
||||
password: ''
|
||||
});
|
||||
const modalTitle = ref('');
|
||||
const deleteConfirm = ref({ show: false, id: null as number | null });
|
||||
|
||||
// Загрузка списка подключений
|
||||
async function loadConnections() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/database-connections');
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
connections.value = data.map((c: any) => ({
|
||||
...c,
|
||||
testing: false,
|
||||
testResult: null
|
||||
}));
|
||||
} catch (e) {
|
||||
showNotification('dbConnections.loadError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
// Тестирование соединения
|
||||
async function testConnection(conn: Connection) {
|
||||
conn.testing = true;
|
||||
conn.testResult = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/database-connections/${conn.id}/test`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
conn.testResult = `${data.latency_ms} ms`;
|
||||
showNotification('dbConnections.testSuccess', 'success', { latency: data.latency_ms });
|
||||
} else {
|
||||
const errorText = data.error || t('dbConnections.testUnknownError');
|
||||
showNotification('dbConnections.testError', 'error', { error: errorText });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showNotification('dbConnections.testNetworkError', 'error', { error: error.message });
|
||||
} finally {
|
||||
conn.testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательные функции для отображения типа
|
||||
function getTypeLabel(type: string) {
|
||||
const labels: Record<string, string> = {
|
||||
mysql: 'MySQL',
|
||||
postgres: 'PostgreSQL',
|
||||
clickhouse: 'ClickHouse'
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
function getTypeBadgeClass(type: string) {
|
||||
const classes: Record<string, string> = {
|
||||
mysql: 'bg-blue-100 text-blue-800',
|
||||
postgres: 'bg-indigo-100 text-indigo-800',
|
||||
clickhouse: 'bg-amber-100 text-amber-800'
|
||||
};
|
||||
return classes[type] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
function openModal(mode: 'create' | 'edit', conn: Connection | null = null) {
|
||||
modalMode.value = mode;
|
||||
if (mode === 'create') {
|
||||
form.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
type: 'mysql',
|
||||
host: '',
|
||||
port: 3306,
|
||||
database: '',
|
||||
user: '',
|
||||
password: ''
|
||||
};
|
||||
modalTitle.value = t('dbConnections.add');
|
||||
} else if (conn) {
|
||||
form.value = {
|
||||
id: conn.id,
|
||||
name: conn.name,
|
||||
type: conn.type,
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
database: conn.database,
|
||||
user: conn.user,
|
||||
password: ''
|
||||
};
|
||||
modalTitle.value = t('dbConnections.edit');
|
||||
}
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen.value = false;
|
||||
}
|
||||
|
||||
async function submitConnection() {
|
||||
if (modalMode.value === 'create' && !form.value.password) {
|
||||
showNotification('dbConnections.passwordRequired', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
name: form.value.name,
|
||||
type: form.value.type,
|
||||
host: form.value.host,
|
||||
port: form.value.port,
|
||||
database: form.value.database,
|
||||
user: form.value.user,
|
||||
};
|
||||
if (form.value.password) {
|
||||
payload.password = form.value.password;
|
||||
}
|
||||
|
||||
if (modalMode.value === 'create') {
|
||||
const res = await fetch('/api/admin/database-connections', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('dbConnections.createSuccess', 'success');
|
||||
} else {
|
||||
const res = await fetch(`/api/admin/database-connections/${form.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('dbConnections.updateSuccess', 'success');
|
||||
}
|
||||
await loadConnections();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
showNotification(modalMode.value === 'create' ? 'dbConnections.createError' : 'dbConnections.updateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
deleteConfirm.value = { show: true, id };
|
||||
}
|
||||
|
||||
async function deleteConnection(id: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/database-connections/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('dbConnections.deleteSuccess', 'success');
|
||||
await loadConnections();
|
||||
} catch (e) {
|
||||
showNotification('dbConnections.deleteError', 'error');
|
||||
} finally {
|
||||
deleteConfirm.value.show = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadConnections);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -9,20 +9,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-6xl font-bold text-gray-900 mb-4">404</h1>
|
||||
<p class="text-xl text-gray-600 mb-8">Oops! Page not found</p>
|
||||
<p class="text-gray-500 mb-8">The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.</p>
|
||||
<p class="text-xl text-gray-600 mb-8">{{ t('notFound.title') }}</p>
|
||||
<p class="text-gray-500 mb-8">{{ t('notFound.message') }}</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
class="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Go to Dashboard
|
||||
{{ t('notFound.goToDashboard') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/login"
|
||||
class="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
{{ t('notFound.signIn') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,5 +30,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No additional logic needed
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
641
frontend/src/views/OlapColumns.vue
Normal file
641
frontend/src/views/OlapColumns.vue
Normal file
@@ -0,0 +1,641 @@
|
||||
<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.typeNormal || 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">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<!-- <button @click="openEditModal(col)" class="text-blue-600 hover:text-blue-800 transition-colors" :title="t('app.edit')">-->
|
||||
<!-- <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="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="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>
|
||||
</div>
|
||||
</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="editModalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeEditModal">
|
||||
<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="flex justify-between items-center p-6 border-b">
|
||||
<h2 class="text-xl font-bold text-gray-900">{{ t('olapColumns.editField') }}</h2>
|
||||
<button @click="closeEditModal" class="text-gray-400 hover:text-gray-600">
|
||||
<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="updateField" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }}</label>
|
||||
<input v-model="editForm.name" type="text" class="input-field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olapColumns.displayType') }}</label>
|
||||
<select v-model="editForm.typeNormal" class="input-field">
|
||||
<option value="string">string</option>
|
||||
<option value="integer">integer</option>
|
||||
<option value="decimal">decimal</option>
|
||||
<option value="datetime">datetime</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" v-model="editForm.aggregationAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||
<label class="text-sm font-medium text-gray-700">{{ t('olapColumns.aggregation') }}</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" v-model="editForm.groupingAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||
<label class="text-sm font-medium text-gray-700">{{ t('olapColumns.grouping') }}</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" v-model="editForm.filteringAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||
<label class="text-sm font-medium text-gray-700">{{ t('olapColumns.filtering') }}</label>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button type="button" @click="closeEditModal" 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="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;
|
||||
typeNormal: 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 editModalOpen = ref(false);
|
||||
const editForm = ref({
|
||||
fieldKey: '',
|
||||
name: '',
|
||||
typeNormal: '',
|
||||
aggregationAllowed: false,
|
||||
groupingAllowed: false,
|
||||
filteringAllowed: false
|
||||
});
|
||||
|
||||
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;
|
||||
editModalOpen.value = 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 openEditModal(col: Column) {
|
||||
editForm.value = {
|
||||
fieldKey: col.fieldKey,
|
||||
name: col.name,
|
||||
typeNormal: col.typeNormal,
|
||||
aggregationAllowed: col.aggregationAllowed,
|
||||
groupingAllowed: col.groupingAllowed,
|
||||
filteringAllowed: col.filteringAllowed
|
||||
};
|
||||
editModalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
editModalOpen.value = false;
|
||||
}
|
||||
|
||||
async function updateField() {
|
||||
try {
|
||||
const res = await fetch(`/api/reports/olap/columns/${encodeURIComponent(editForm.value.fieldKey)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: editForm.value.name,
|
||||
typeNormal: editForm.value.typeNormal,
|
||||
aggregationAllowed: editForm.value.aggregationAllowed,
|
||||
groupingAllowed: editForm.value.groupingAllowed,
|
||||
filteringAllowed: editForm.value.filteringAllowed
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('olapColumns.updateSuccess', 'success');
|
||||
closeEditModal();
|
||||
await loadColumns();
|
||||
} catch (error) {
|
||||
showNotification('olapColumns.updateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
1389
frontend/src/views/OlapConstructor.vue
Normal file
1389
frontend/src/views/OlapConstructor.vue
Normal file
File diff suppressed because it is too large
Load Diff
147
frontend/src/views/OlapQueries.vue
Normal file
147
frontend/src/views/OlapQueries.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('olapQueries.title') }}</h1>
|
||||
<router-link to="/olap/constructor" class="btn-primary">+ {{ t('olapQueries.createButton') }}</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.active') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapQueries.lastRun') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapQueries.result') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapQueries.connection') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('app.restaurants') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr v-for="q in queries" :key="q.id" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 text-sm">{{ q.id }}</td>
|
||||
<td class="px-6 py-4 text-sm font-medium">{{ q.name }}</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<span :class="q.active ? 'text-green-600' : 'text-red-600'">
|
||||
{{ q.active ? t('common.yes') : t('common.no') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">{{ q.lastRun ? formatDate(q.lastRun) : '—' }}</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<span v-if="q.lastRunSuccess === null">—</span>
|
||||
<span v-else-if="q.lastRunSuccess" class="text-green-600">{{ t('olapQueries.success') }}</span>
|
||||
<span v-else class="text-red-600">{{ t('olapQueries.error') }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">{{ q.dbConnectionName }}</td>
|
||||
<td class="px-6 py-4 text-sm">{{ q.restaurants }}</td>
|
||||
<td class="px-6 py-4 text-sm">{{ formatDate(q.created) }}</td>
|
||||
<td class="px-6 py-4 text-right space-x-2">
|
||||
<router-link :to="`/olap/constructor/${q.id}`" class="text-blue-600 hover:text-blue-800">
|
||||
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</router-link>
|
||||
<button @click="confirmDelete(q.id)" class="text-red-600 hover:text-red-800">
|
||||
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="queries.length === 0">
|
||||
<td colspan="6" class="px-6 py-12 text-center text-gray-500">{{ t('olapQueries.noQueries') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно удаления с улучшенной стилизацией -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="deleteModal.show" class="fixed inset-0 z-[9999] overflow-y-auto" @click.self="deleteModal.show = false">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
|
||||
<div class="p-6 text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('olapQueries.deleteQueriesTitle') }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">{{ t('olapQueries.deleteQueriesMessage') }}</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="deleteModal.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button @click="deleteQuery" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue'
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { showNotification } = useNotification()
|
||||
const queries = ref([])
|
||||
const deleteModal = ref({ show: false, id: null as number | null })
|
||||
|
||||
async function loadQueries() {
|
||||
try {
|
||||
const res = await fetch('/api/olap/queries')
|
||||
if (!res.ok) throw new Error()
|
||||
queries.value = await res.json()
|
||||
} catch (e) {
|
||||
showNotification('olapQueries.loadError', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
return dateStr ? new Date(dateStr).toLocaleString() : '-'
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
deleteModal.value = { show: true, id }
|
||||
}
|
||||
|
||||
async function deleteQuery() {
|
||||
const id = deleteModal.value.id
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await fetch(`/api/olap/queries/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error()
|
||||
showNotification('olapQueries.deleteSuccess', 'success')
|
||||
await loadQueries()
|
||||
} catch (e) {
|
||||
showNotification('olapQueries.deleteError', 'error')
|
||||
} finally {
|
||||
deleteModal.value.show = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadQueries)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
144
frontend/src/views/Profile.vue
Normal file
144
frontend/src/views/Profile.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="card">
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<div class="relative">
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white text-2xl font-bold">
|
||||
{{ userInitials }}
|
||||
</div>
|
||||
<!-- <button class="absolute bottom-0 right-0 p-1 bg-white rounded-full shadow-md hover:shadow-lg transition-shadow">
|
||||
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button> -->
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('profile.title') }}</h1>
|
||||
<p class="text-gray-500">{{ t('profile.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveProfile" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.username') }}</label>
|
||||
<input v-model="userStore.login" type="text" disabled class="input-field bg-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.role') }}</label>
|
||||
<div class="relative">
|
||||
<input :value="userStore.role === 'admin' ? t('app.administrator') : t('app.user')" type="text" disabled class="input-field bg-gray-100" />
|
||||
<div class="absolute right-3 top-2.5">
|
||||
<span class="px-2 py-0.5 text-xs rounded-full" :class="userStore.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'">
|
||||
{{ userStore.role === 'admin' ? 'Admin' : 'User' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.email') }}</label>
|
||||
<input v-model="form.email" type="email" required class="input-field" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('profile.newPassword') }}</label>
|
||||
<input v-model="form.password" type="password" class="input-field" autocomplete="new-password" />
|
||||
<p class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('profile.confirmPassword') }}</label>
|
||||
<input v-model="form.confirmPassword" type="password" class="input-field" :class="{ 'border-red-300': passwordMismatch }" />
|
||||
<p v-if="passwordMismatch" class="text-xs text-red-600 mt-1">Passwords do not match</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('app.language') }}</label>
|
||||
<select v-model="form.language" class="input-field">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="resetForm" class="btn-secondary">{{ t('settings.reset') }}</button>
|
||||
<button type="submit" :disabled="loading" class="btn-primary flex items-center gap-2">
|
||||
<svg v-if="loading" class="animate-spin h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></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>
|
||||
{{ t('settings.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import {useNotification} from "@/composables/useNotification";
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const userStore = useUserStore();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
language: 'en'
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const userInitials = computed(() => (userStore.login[0] || 'U').toUpperCase());
|
||||
const passwordMismatch = computed(() => !!form.password && form.password !== form.confirmPassword);
|
||||
|
||||
function resetForm() {
|
||||
form.email = userStore.email;
|
||||
form.password = '';
|
||||
form.confirmPassword = '';
|
||||
form.language = userStore.language;
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (form.password && form.password !== form.confirmPassword) {
|
||||
showNotification('profile.passwordsMismatch', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const updates: any = {
|
||||
email: form.email,
|
||||
language: form.language
|
||||
};
|
||||
if (form.password) updates.password = form.password;
|
||||
|
||||
const ok = await userStore.updateProfile(updates);
|
||||
loading.value = false;
|
||||
|
||||
if (ok) {
|
||||
locale.value = form.language;
|
||||
showNotification('profile.updateSuccess', 'success');
|
||||
resetForm();
|
||||
} else {
|
||||
showNotification('profile.updateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
form.email = userStore.email;
|
||||
form.language = userStore.language;
|
||||
});
|
||||
</script>
|
||||
@@ -1,83 +1,196 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Restaurants</h1>
|
||||
<button @click="openModal('create')" class="btn-primary">+ Add Restaurant</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('restaurants.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('restaurants.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card 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">ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Login</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Host</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="rest in restaurants" :key="rest.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.login }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
|
||||
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800">Edit</button>
|
||||
<button @click="deleteRestaurant(rest.id)" class="text-red-600 hover:text-red-800">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="modalOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2>
|
||||
<form @submit.prevent="submitRestaurant">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input v-model="form.name" type="text" required class="input-field mt-1" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Login</label>
|
||||
<input v-model="form.login" type="text" required class="input-field mt-1" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input v-model="form.password" :required="modalMode === 'create'" type="password" class="input-field mt-1" />
|
||||
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Host</label>
|
||||
<input v-model="form.host" type="text" required class="input-field mt-1" />
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<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('restaurants.host') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('restaurants.https') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.login') }}</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="rest in restaurants" :key="rest.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ rest.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" :checked="rest.https" @change="toggleHttps(rest)" class="sr-only peer" />
|
||||
<div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div>
|
||||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||||
</label>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.login }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.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="checkRestaurant(rest)" :disabled="rest.checking" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50">
|
||||
<svg v-if="!rest.checking" 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', rest)" 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(rest.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="rest.checkResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
|
||||
{{ rest.checkResult }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="restaurants.length === 0">
|
||||
<td colspan="7" class="px-6 py-12 text-center text-gray-500">{{ t('restaurants.noRestaurants') }}</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="submitRestaurant" 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('restaurants.host') }} *</label>
|
||||
<input v-model="form.host" type="text" required class="input-field" placeholder="e.g., api.example.com" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" v-model="form.https" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||
<label class="text-sm font-medium text-gray-700">{{ t('restaurants.useHttps') }}</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.login') }} *</label>
|
||||
<input v-model="form.login" 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('restaurants.delete') }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">{{ t('restaurants.deleteConfirmation') }}</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button @click="deleteRestaurant(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 AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useNotification } from '@/composables/useNotification';
|
||||
|
||||
const restaurants = ref([]);
|
||||
const { t } = useI18n();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
type Restaurant = {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
https: boolean;
|
||||
login: string;
|
||||
created: string;
|
||||
checking?: boolean;
|
||||
checkResult?: string | null;
|
||||
};
|
||||
|
||||
const restaurants = ref<Restaurant[]>([]);
|
||||
const modalOpen = ref(false);
|
||||
const modalMode = ref<'create' | 'edit'>('create');
|
||||
const form = ref({ id: null, name: '', login: '', password: '', host: '' });
|
||||
const form = ref({ id: null, name: '', login: '', password: '', host: '', https: false });
|
||||
const modalTitle = ref('');
|
||||
const deleteConfirm = ref({ show: false, id: null });
|
||||
|
||||
async function loadRestaurants() {
|
||||
const res = await fetch('/api/admin/restaurants');
|
||||
restaurants.value = await res.json();
|
||||
try {
|
||||
const res = await fetch('/api/admin/restaurants');
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
restaurants.value = data.map((r: any) => ({
|
||||
...r,
|
||||
checking: false,
|
||||
checkResult: null
|
||||
}));
|
||||
} catch (e) {
|
||||
showNotification('restaurants.loadError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
@@ -85,14 +198,67 @@ function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
function openModal(mode: 'create' | 'edit', rest: any = null) {
|
||||
async function checkRestaurant(rest: Restaurant) {
|
||||
rest.checking = true;
|
||||
rest.checkResult = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/restaurants/${rest.id}/check`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
rest.checkResult = `${data.latency_ms} ms`;
|
||||
} else {
|
||||
const errorText = data.error || 'Unknown error';
|
||||
showNotification('restaurants.checkError', 'error', { error: errorText });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showNotification('restaurants.checkNetworkError', 'error', { error: error.message });
|
||||
} finally {
|
||||
rest.checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHttps(rest: Restaurant) {
|
||||
const newHttps = !rest.https;
|
||||
const payload = {
|
||||
name: rest.name,
|
||||
host: rest.host,
|
||||
login: rest.login,
|
||||
https: newHttps
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`/api/admin/restaurants/${rest.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
rest.https = newHttps;
|
||||
showNotification('restaurants.httpsUpdateSuccess', 'success');
|
||||
} else {
|
||||
showNotification('restaurants.httpsUpdateError', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('restaurants.httpsUpdateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(mode: 'create' | 'edit', rest: Restaurant | null = null) {
|
||||
modalMode.value = mode;
|
||||
if (mode === 'create') {
|
||||
form.value = { id: null, name: '', login: '', password: '', host: '' };
|
||||
modalTitle.value = 'Create Restaurant';
|
||||
} else {
|
||||
form.value = { id: rest.id, name: rest.name, login: rest.login, password: '', host: rest.host };
|
||||
modalTitle.value = 'Edit Restaurant';
|
||||
form.value = { id: null, name: '', login: '', password: '', host: '', https: false };
|
||||
modalTitle.value = t('restaurants.add');
|
||||
} else if (rest) {
|
||||
form.value = {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
login: rest.login,
|
||||
password: '',
|
||||
host: rest.host,
|
||||
https: rest.https || false
|
||||
};
|
||||
modalTitle.value = t('restaurants.edit');
|
||||
}
|
||||
modalOpen.value = true;
|
||||
}
|
||||
@@ -102,39 +268,76 @@ function closeModal() {
|
||||
}
|
||||
|
||||
async function submitRestaurant() {
|
||||
if (modalMode.value === 'create' && !form.value.password) {
|
||||
showNotification('restaurants.passwordRequired', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
login: form.value.login,
|
||||
host: form.value.host,
|
||||
https: form.value.https,
|
||||
login: form.value.login,
|
||||
...(form.value.password ? { password: form.value.password } : {})
|
||||
};
|
||||
if (modalMode.value === 'create') {
|
||||
await fetch('/api/admin/restaurants', {
|
||||
const res = await fetch('/api/admin/restaurants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('restaurants.createSuccess', 'success');
|
||||
} else {
|
||||
await fetch(`/api/admin/restaurants/${form.value.id}`, {
|
||||
const res = await fetch(`/api/admin/restaurants/${form.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('restaurants.updateSuccess', 'success');
|
||||
}
|
||||
await loadRestaurants();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
alert('Operation failed');
|
||||
showNotification(modalMode.value === 'create' ? 'restaurants.createError' : 'restaurants.updateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
deleteConfirm.value = { show: true, id };
|
||||
}
|
||||
|
||||
async function deleteRestaurant(id: number) {
|
||||
if (confirm('Are you sure?')) {
|
||||
await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' });
|
||||
try {
|
||||
const res = await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('restaurants.deleteSuccess', 'success');
|
||||
await loadRestaurants();
|
||||
} catch (e) {
|
||||
showNotification('restaurants.deleteError', 'error');
|
||||
} finally {
|
||||
deleteConfirm.value.show = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRestaurants);
|
||||
</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>
|
||||
|
||||
@@ -1,85 +1,188 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Users Management</h1>
|
||||
<button @click="openModal('create')" class="btn-primary">+ Add User</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('users.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('users.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card 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">ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Login</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Active</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">IP</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.login }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.email }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button @click="toggleActive(user)" :class="user.active ? 'text-green-600' : 'text-red-600'">
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.ip || '-' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ formatDate(user.created) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
|
||||
<button @click="openModal('edit', user)" class="text-blue-600">Edit</button>
|
||||
<button @click="deleteUser(user.id)" class="text-red-600">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="modalOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2>
|
||||
<form @submit.prevent="submitUser">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input v-model="form.email" type="text" required class="input-field mt-1" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Login</label>
|
||||
<input v-model="form.login" type="text" required class="input-field mt-1" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input v-model="form.password" :required="modalMode === 'create'" type="password" class="input-field mt-1" />
|
||||
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<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.login') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.email') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.role') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.status') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.ip') }}</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="user in users" :key="user.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ user.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ user.login }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ user.email }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span class="px-2 py-1 text-xs rounded-full" :class="user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'">
|
||||
{{ user.role === 'admin' ? t('app.administrator') : t('app.user') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<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"
|
||||
:checked="user.active"
|
||||
@change="toggleActive(user)"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div>
|
||||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||||
</label>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ user.ip || '-' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(user.created) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||
<button @click="openModal('edit', user)" class="text-blue-600 hover:text-blue-800 transition-colors">
|
||||
<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>
|
||||
</button>
|
||||
<button
|
||||
v-if="user.id !== userStore.id"
|
||||
@click="confirmDelete(user.id)"
|
||||
class="text-red-600 hover:text-red-800 transition-colors"
|
||||
>
|
||||
<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="users.length === 0">
|
||||
<td colspan="8" class="px-6 py-12 text-center text-gray-500">{{ t('users.noUsers') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for create/edit user -->
|
||||
<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="submitUser" class="p-6 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.email') }} *</label>
|
||||
<input v-model="form.email" type="email" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.login') }} *</label>
|
||||
<input v-model="form.login" type="text" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.role') }}</label>
|
||||
<select v-model="form.role" class="input-field" :disabled="isEditingSelf">
|
||||
<option value="user">{{ t('app.user') }}</option>
|
||||
<option value="admin">{{ t('app.administrator') }}</option>
|
||||
</select>
|
||||
<p v-if="isEditingSelf" class="text-xs text-amber-600 mt-1">{{ t('users.cannotChangeOwnRole') }}</p>
|
||||
</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>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<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('users.delete') }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">{{ t('users.deleteConfirmation') }}</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button @click="deleteUser(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 { ref, onMounted, computed } from 'vue';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useNotification } from '@/composables/useNotification';
|
||||
|
||||
const users = ref([]);
|
||||
const { t } = useI18n();
|
||||
const { showNotification } = useNotification();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const users = ref<any[]>([]);
|
||||
const modalOpen = ref(false);
|
||||
const modalMode = ref<'create' | 'edit'>('create');
|
||||
const form = ref({ id: null, login: '', password: '' });
|
||||
const form = ref({ id: null, login: '', email: '', password: '', role: 'user' });
|
||||
const modalTitle = ref('');
|
||||
const deleteConfirm = ref({ show: false, id: null });
|
||||
|
||||
const isEditingSelf = computed(() => {
|
||||
return modalMode.value === 'edit' && form.value.id === userStore.id;
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const res = await fetch('/api/admin/users');
|
||||
users.value = await res.json();
|
||||
try {
|
||||
const res = await fetch('/api/admin/users');
|
||||
if (!res.ok) throw new Error();
|
||||
users.value = await res.json();
|
||||
} catch (e) {
|
||||
showNotification('users.loadError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
@@ -88,18 +191,24 @@ function formatDate(dateStr: string) {
|
||||
}
|
||||
|
||||
async function toggleActive(user: any) {
|
||||
await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' })
|
||||
await loadUsers()
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' });
|
||||
if (!res.ok) throw new Error();
|
||||
await loadUsers();
|
||||
showNotification('users.statusUpdated', 'success');
|
||||
} catch (e) {
|
||||
showNotification('users.statusUpdateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(mode: 'create' | 'edit', user: any = null) {
|
||||
modalMode.value = mode;
|
||||
if (mode === 'create') {
|
||||
form.value = { id: null, login: '', password: '' };
|
||||
modalTitle.value = 'Create User';
|
||||
form.value = { id: null, login: '', email: '', password: '', role: 'user' };
|
||||
modalTitle.value = t('users.add');
|
||||
} else {
|
||||
form.value = { id: user.id, login: user.login, password: '' };
|
||||
modalTitle.value = 'Edit User';
|
||||
form.value = { id: user.id, login: user.login, email: user.email, password: '', role: user.role || 'user' };
|
||||
modalTitle.value = t('users.edit');
|
||||
}
|
||||
modalOpen.value = true;
|
||||
}
|
||||
@@ -109,33 +218,81 @@ function closeModal() {
|
||||
}
|
||||
|
||||
async function submitUser() {
|
||||
if (isEditingSelf.value && form.value.role !== userStore.role) {
|
||||
showNotification('users.cannotChangeOwnRole', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalMode.value === 'create' && !form.value.password) {
|
||||
showNotification('users.passwordRequired', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
login: form.value.login,
|
||||
email: form.value.email,
|
||||
role: form.value.role,
|
||||
};
|
||||
if (form.value.password) {
|
||||
payload.password = form.value.password;
|
||||
}
|
||||
|
||||
let response;
|
||||
if (modalMode.value === 'create') {
|
||||
await fetch('/api/admin/users', {
|
||||
response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login: form.value.login, password: form.value.password })
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error();
|
||||
showNotification('users.createSuccess', 'success');
|
||||
} else {
|
||||
await fetch(`/api/admin/users/${form.value.id}`, {
|
||||
response = await fetch(`/api/admin/users/${form.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login: form.value.login, password: form.value.password || undefined })
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error();
|
||||
showNotification('users.updateSuccess', 'success');
|
||||
}
|
||||
|
||||
await loadUsers();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
alert('Operation failed');
|
||||
showNotification(modalMode.value === 'create' ? 'users.createError' : 'users.updateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
deleteConfirm.value = { show: true, id };
|
||||
}
|
||||
|
||||
async function deleteUser(id: number) {
|
||||
if (confirm('Are you sure?')) {
|
||||
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('users.deleteSuccess', 'success');
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
showNotification('users.deleteError', 'error');
|
||||
} finally {
|
||||
deleteConfirm.value.show = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers);
|
||||
onMounted(async () => {
|
||||
await loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Welcome Back</h1>
|
||||
<p class="text-gray-600 mt-2">Sign in to your account</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ t('login.title') }}</h1>
|
||||
<p class="text-gray-600 mt-2">{{ t('login.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('login.username') }}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -28,13 +28,13 @@
|
||||
type="text"
|
||||
required
|
||||
class="input-field pl-10"
|
||||
placeholder="Enter your username"
|
||||
placeholder="Enter your username or email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.password') }}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -65,10 +65,14 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
<span class="ml-2 text-sm text-gray-600">Remember me</span>
|
||||
<span class="ml-2 text-sm text-gray-600">{{ t('login.remember') }}</span>
|
||||
</label>
|
||||
<router-link to="/register" class="text-sm text-primary-600 hover:text-primary-700">
|
||||
Create account
|
||||
<router-link
|
||||
v-if="settings.enableRegistration"
|
||||
to="/register"
|
||||
class="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{{ t('login.createAccount') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +81,7 @@
|
||||
:disabled="loading"
|
||||
class="w-full btn-primary py-3 relative"
|
||||
>
|
||||
<span v-if="!loading">Sign In</span>
|
||||
<span v-if="!loading">{{ t('login.signin') }}</span>
|
||||
<svg v-else class="animate-spin h-5 w-5 mx-auto text-white" 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>
|
||||
@@ -92,6 +96,12 @@
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Блок версии внизу -->
|
||||
<div class="mt-6 text-center text-xs text-gray-500">
|
||||
{{ versionStore.getFormattedVersion(t) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -99,8 +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 { 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('')
|
||||
@@ -109,21 +127,26 @@ const showPassword = ref(false)
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
await userStore.fetchProfile()
|
||||
// Устанавливаем язык интерфейса из профиля
|
||||
if (userStore.language) {
|
||||
locale.value = userStore.language
|
||||
localStorage.setItem('locale', userStore.language)
|
||||
}
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
error.value = 'Invalid username or password'
|
||||
const text = await res.text()
|
||||
error.value = text || t('login.invalidCredentials')
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Network error. Please try again.'
|
||||
error.value = t('login.networkError')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -7,40 +7,50 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Create Account</h1>
|
||||
<p class="text-gray-600 mt-2">Register and wait for admin approval</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ t('register.title') }}</h1>
|
||||
<p class="text-gray-600 mt-2">{{ t('register.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<form @submit.prevent="handleRegister" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('register.username') }}</label>
|
||||
<input v-model="form.login" type="text" required minlength="3" class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.email') }}</label>
|
||||
<input v-model="form.email" type="email" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.password') }}</label>
|
||||
<input v-model="form.password" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="w-full btn-primary py-3">
|
||||
<span v-if="!loading">Register</span>
|
||||
<span v-if="!loading">{{ t('register.register') }}</span>
|
||||
<span v-else>Loading...</span>
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="success" class="mt-4 text-green-600 text-center">Account created! Wait for admin activation.</p>
|
||||
<p v-if="success" class="mt-4 text-green-600 text-center">{{ t('register.success') }}</p>
|
||||
<p v-if="error" class="mt-4 text-red-600 text-center">{{ error }}</p>
|
||||
<p class="mt-4 text-center text-sm text-gray-600">
|
||||
Already have an account? <router-link to="/login" class="text-primary-600">Login</router-link>
|
||||
{{ 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>
|
||||
|
||||
<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('')
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Setup Admin Account</h1>
|
||||
<p class="text-gray-600 mt-2">Create your administrator account</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ t('setup.title') }}</h1>
|
||||
<p class="text-gray-600 mt-2">{{ t('setup.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Setup Form -->
|
||||
@@ -19,19 +19,19 @@
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center text-white font-semibold">1</div>
|
||||
<div class="ml-2 text-sm font-medium text-gray-900">Account Details</div>
|
||||
<div class="ml-2 text-sm font-medium text-gray-900">{{ t('setup.step1') }}</div>
|
||||
</div>
|
||||
<div class="mx-4 w-12 h-0.5 bg-gray-300"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center text-gray-600 font-semibold">2</div>
|
||||
<div class="ml-2 text-sm font-medium text-gray-500">Complete</div>
|
||||
<div class="ml-2 text-sm font-medium text-gray-500">{{ t('setup.step2') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSetup" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.username') }}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.email') }}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -72,7 +72,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.password') }}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -105,7 +105,7 @@
|
||||
<!-- Password Strength -->
|
||||
<div v-if="form.password" class="mt-2">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-600">Password strength</span>
|
||||
<span class="text-xs text-gray-600">{{ t('setup.passwordStrength') }}</span>
|
||||
<span class="text-xs font-medium" :class="strengthColor">{{ strengthText }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
@@ -123,7 +123,7 @@
|
||||
:disabled="loading"
|
||||
class="w-full btn-primary py-3 relative overflow-hidden group"
|
||||
>
|
||||
<span v-if="!loading" class="relative z-10">Create Account</span>
|
||||
<span v-if="!loading" class="relative z-10">{{ t('setup.createAccount') }}</span>
|
||||
<svg v-else class="animate-spin h-5 w-5 mx-auto text-white" 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>
|
||||
@@ -151,7 +151,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const form = ref({ login: '', email: '', password: '' });
|
||||
const loading = ref(false)
|
||||
@@ -186,8 +187,9 @@ const passwordStrength = computed(() => {
|
||||
const strengthPercent = computed(() => (passwordStrength.value / 5) * 100)
|
||||
|
||||
const strengthText = computed(() => {
|
||||
const texts = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong']
|
||||
return texts[passwordStrength.value - 1] || ''
|
||||
const keys = ['setup.veryWeak', 'setup.weak', 'setup.fair', 'setup.good', 'setup.strong']
|
||||
const key = keys[passwordStrength.value - 1]
|
||||
return key ? t(key) : ''
|
||||
})
|
||||
|
||||
const strengthColor = computed(() => {
|
||||
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "vue",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080' // для разработки
|
||||
|
||||
59
iiko-app.dev.xserver.su.nginx.conf
Normal file
59
iiko-app.dev.xserver.su.nginx.conf
Normal file
@@ -0,0 +1,59 @@
|
||||
server {
|
||||
server_name iiko-app.dev.xserver.su;
|
||||
|
||||
# HSTS (защита от понижения HTTPS)
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Защитные заголовки
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin;
|
||||
|
||||
location / {
|
||||
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:7104;
|
||||
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;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = iiko-app.dev.xserver.su) {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
listen 80;
|
||||
server_name iiko-app.dev.xserver.su;
|
||||
return 404;
|
||||
}
|
||||
BIN
libs/RoaringBitmap-1.0.6.jar
Normal file
BIN
libs/RoaringBitmap-1.0.6.jar
Normal file
Binary file not shown.
BIN
libs/antlr4-runtime-4.13.2.jar
Normal file
BIN
libs/antlr4-runtime-4.13.2.jar
Normal file
Binary file not shown.
BIN
libs/asm-9.7.jar
Normal file
BIN
libs/asm-9.7.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-client-0.9.8.jar
Normal file
BIN
libs/clickhouse-client-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-data-0.9.8.jar
Normal file
BIN
libs/clickhouse-data-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-http-client-0.9.8.jar
Normal file
BIN
libs/clickhouse-http-client-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-jdbc-0.9.8.jar
Normal file
BIN
libs/clickhouse-jdbc-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/client-v2-0.9.8.jar
Normal file
BIN
libs/client-v2-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/commons-codec-1.19.0.jar
Normal file
BIN
libs/commons-codec-1.19.0.jar
Normal file
Binary file not shown.
BIN
libs/commons-compress-1.28.0.jar
Normal file
BIN
libs/commons-compress-1.28.0.jar
Normal file
Binary file not shown.
BIN
libs/commons-io-2.20.0.jar
Normal file
BIN
libs/commons-io-2.20.0.jar
Normal file
Binary file not shown.
BIN
libs/commons-lang3-3.20.0.jar
Normal file
BIN
libs/commons-lang3-3.20.0.jar
Normal file
Binary file not shown.
BIN
libs/error_prone_annotations-2.36.0.jar
Normal file
BIN
libs/error_prone_annotations-2.36.0.jar
Normal file
Binary file not shown.
BIN
libs/failureaccess-1.0.3.jar
Normal file
BIN
libs/failureaccess-1.0.3.jar
Normal file
Binary file not shown.
BIN
libs/guava-33.4.6-jre.jar
Normal file
BIN
libs/guava-33.4.6-jre.jar
Normal file
Binary file not shown.
BIN
libs/httpclient5-5.4.4.jar
Normal file
BIN
libs/httpclient5-5.4.4.jar
Normal file
Binary file not shown.
BIN
libs/httpcore5-5.3.4.jar
Normal file
BIN
libs/httpcore5-5.3.4.jar
Normal file
Binary file not shown.
BIN
libs/httpcore5-h2-5.3.4.jar
Normal file
BIN
libs/httpcore5-h2-5.3.4.jar
Normal file
Binary file not shown.
BIN
libs/j2objc-annotations-3.0.0.jar
Normal file
BIN
libs/j2objc-annotations-3.0.0.jar
Normal file
Binary file not shown.
BIN
libs/jdbc-v2-0.9.8.jar
Normal file
BIN
libs/jdbc-v2-0.9.8.jar
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
libs/lz4-java-1.10.4.jar
Normal file
BIN
libs/lz4-java-1.10.4.jar
Normal file
Binary file not shown.
BIN
libs/mysql-connector-j-9.7.0.jar
Normal file
BIN
libs/mysql-connector-j-9.7.0.jar
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
libs/postgresql-42.7.11.jar
Normal file
BIN
libs/postgresql-42.7.11.jar
Normal file
Binary file not shown.
BIN
libs/protobuf-java-4.31.1.jar
Normal file
BIN
libs/protobuf-java-4.31.1.jar
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
libs/vertx-core-logging-5.0.12.jar
Normal file
BIN
libs/vertx-core-logging-5.0.12.jar
Normal file
Binary file not shown.
Binary file not shown.
BIN
libs/vertx-jdbc-client-5.0.12.jar
Normal file
BIN
libs/vertx-jdbc-client-5.0.12.jar
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
libs/vertx-web-sstore-redis-5.0.12.jar
Normal file
BIN
libs/vertx-web-sstore-redis-5.0.12.jar
Normal file
Binary file not shown.
51
src/main/java/su/xserver/iikocon/BuildVersionProvider.java
Normal file
51
src/main/java/su/xserver/iikocon/BuildVersionProvider.java
Normal file
@@ -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 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;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package su.xserver.iikocon;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
public class DateRangeSetup {
|
||||
public static void main(String[] args) {
|
||||
// Параметры по умолчанию
|
||||
String login = "4444";
|
||||
String password = "4444";
|
||||
String server = "folk-amber-co.iiko.it";
|
||||
String presetId = "7ddc40c3-9d5f-408f-aa1e-652964b36c6c";
|
||||
|
||||
// Вычисление dateFrom и dateTo
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate dateFrom = today.minusDays(7);
|
||||
LocalDate dateTo = today;
|
||||
|
||||
// Переопределение из аргументов командной строки
|
||||
if (args.length > 0 && args[0] != null && !args[0].isEmpty()) {
|
||||
try {
|
||||
dateFrom = LocalDate.parse(args[0]);
|
||||
} catch (DateTimeParseException e) {
|
||||
System.err.println("Ошибка парсинга dateFrom: " + args[0] + ". Используется значение по умолчанию.");
|
||||
}
|
||||
}
|
||||
|
||||
if (args.length > 1 && args[1] != null && !args[1].isEmpty()) {
|
||||
try {
|
||||
dateTo = LocalDate.parse(args[1]);
|
||||
} catch (DateTimeParseException e) {
|
||||
System.err.println("Ошибка парсинга dateTo: " + args[1] + ". Используется значение по умолчанию.");
|
||||
}
|
||||
}
|
||||
|
||||
// Форматирование дат в YYYY-MM-DD
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
String formattedDateFrom = dateFrom.format(formatter);
|
||||
String formattedDateTo = dateTo.format(formatter);
|
||||
|
||||
// Вывод переменных (можно заменить на дальнейшее использование)
|
||||
System.out.println("login=" + login);
|
||||
System.out.println("password=" + password);
|
||||
System.out.println("server=" + server);
|
||||
System.out.println("presetId=" + presetId);
|
||||
System.out.println("dateFrom=" + formattedDateFrom);
|
||||
System.out.println("dateTo=" + formattedDateTo);
|
||||
}
|
||||
}
|
||||
@@ -6,40 +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.service.DataBaseService;
|
||||
import su.xserver.iikocon.service.HealthCheckService;
|
||||
import su.xserver.iikocon.service.RedisService;
|
||||
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")
|
||||
@@ -59,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);
|
||||
@@ -77,31 +96,143 @@ 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);
|
||||
});
|
||||
|
||||
Router router = initRouter();
|
||||
|
||||
startHttp(router, startPromise);
|
||||
createRouterAndStartHttp(startPromise);
|
||||
|
||||
})
|
||||
.onFailure(startPromise::fail);
|
||||
|
||||
}
|
||||
|
||||
private Router initRouter() {
|
||||
private void createRouterAndStartHttp(Promise<Void> startPromise) {
|
||||
settingsService.get("session_timeout_minutes")
|
||||
.compose(timeoutStr -> {
|
||||
long timeoutMinutes = 60;
|
||||
if (timeoutStr != null && !timeoutStr.isEmpty()) {
|
||||
try {
|
||||
timeoutMinutes = Long.parseLong(timeoutStr);
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
long timeoutMs = timeoutMinutes * 60 * 1000;
|
||||
|
||||
// Настройка сессий (используем LocalSessionStore для простоты)
|
||||
SessionStore sessionStore = LocalSessionStore.create(vertx);
|
||||
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
||||
.setSessionCookieName("admin.session")
|
||||
.setCookieHttpOnlyFlag(true)
|
||||
.setCookieSecureFlag(false)
|
||||
.setSessionTimeout(3600000);
|
||||
sessionStore = RedisSessionStore.create(vertx, redis.getRedis());
|
||||
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
||||
.setSessionCookieName("admin.session")
|
||||
.setCookieHttpOnlyFlag(true)
|
||||
.setCookieSecureFlag(false)
|
||||
.setSessionTimeout(timeoutMs);
|
||||
|
||||
Router router = initRouter(sessionHandler);
|
||||
startHttp(router, startPromise);
|
||||
return Future.succeededFuture();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
log.error("Failed to get session timeout", err);
|
||||
startPromise.fail(err);
|
||||
});
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
// CORS для разработки
|
||||
router.route().handler(ctx -> {
|
||||
ctx.response()
|
||||
@@ -117,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"));
|
||||
|
||||
@@ -133,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);
|
||||
@@ -149,7 +309,11 @@ public class MainVerticle extends AbstractVerticle {
|
||||
|
||||
router.post("/api/logout").handler(authHandler::handleLogout);
|
||||
|
||||
router.post("/api/register").handler(rc -> {
|
||||
router.post("/api/register").handler(rc -> settingsService.get("enable_registration").onComplete(regCheck -> {
|
||||
if (regCheck.succeeded() && "false".equals(regCheck.result())) {
|
||||
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");
|
||||
@@ -162,10 +326,30 @@ public class MainVerticle extends AbstractVerticle {
|
||||
userService.createUser(login, email, password, ip)
|
||||
.onSuccess(v -> rc.response().setStatusCode(201).end(new JsonObject().put("success", true).encode()))
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
}));
|
||||
|
||||
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/profile").handler(rc -> {
|
||||
Integer userId = rc.session().get("userId");
|
||||
JsonObject body = rc.body().asJsonObject();
|
||||
String email = body.getString("email");
|
||||
String password = body.getString("password");
|
||||
String language = body.getString("language");
|
||||
userService.updateProfile(userId, email, password, language)
|
||||
.onSuccess(v -> {
|
||||
if (language != null) 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.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
rc.response()
|
||||
@@ -176,17 +360,20 @@ 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");
|
||||
String email = body.getString("email");
|
||||
String password = body.getString("password");
|
||||
String role = body.getString("role");
|
||||
String ip = rc.request().remoteAddress().host();
|
||||
if (login == null || email == null || password == null) {
|
||||
rc.response().setStatusCode(400).end("Missing login, email or password");
|
||||
return;
|
||||
}
|
||||
userService.createUser(login, email, password, ip, true)
|
||||
if (role == null || role.isEmpty()) role = "user";
|
||||
userService.createUser(login, email, password, ip, true, role)
|
||||
.onSuccess(v -> rc.response().setStatusCode(201).end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
@@ -197,18 +384,28 @@ public class MainVerticle extends AbstractVerticle {
|
||||
String login = body.getString("login");
|
||||
String email = body.getString("email");
|
||||
String password = body.getString("password");
|
||||
String role = body.getString("role");
|
||||
String ip = rc.request().remoteAddress().host();
|
||||
if (login == null || email == null) {
|
||||
rc.response().setStatusCode(400).end("Missing login or email");
|
||||
return;
|
||||
}
|
||||
userService.updateUser(id, login, email, password, ip)
|
||||
userService.updateUser(id, login, email, password, ip, role)
|
||||
.onSuccess(v -> rc.response().end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
router.delete("/api/admin/users/:id").handler(rc -> {
|
||||
int id = Integer.parseInt(rc.pathParam("id"));
|
||||
Integer currentUserId = rc.session().get("userId");
|
||||
|
||||
if (currentUserId != null && currentUserId == id) {
|
||||
rc.response().setStatusCode(403).end(new JsonObject()
|
||||
.put("error", "You cannot delete your own account")
|
||||
.encode());
|
||||
return;
|
||||
}
|
||||
|
||||
userService.deleteUser(id)
|
||||
.onSuccess(v -> rc.response().end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
@@ -216,27 +413,19 @@ public class MainVerticle extends AbstractVerticle {
|
||||
|
||||
router.put("/api/admin/users/:id/activate").handler(rc -> {
|
||||
int id = Integer.parseInt(rc.pathParam("id"));
|
||||
boolean active = Boolean.parseBoolean(rc.queryParam("active").get(0));
|
||||
boolean active = Boolean.parseBoolean(rc.queryParam("active").getFirst());
|
||||
Integer currentUserId = rc.session().get("userId");
|
||||
|
||||
if (currentUserId != null && currentUserId == id) {
|
||||
rc.response().setStatusCode(403).end(new JsonObject().put("error", "You cannot deactivate yourself").encode());
|
||||
return;
|
||||
}
|
||||
|
||||
userService.setActive(id, active)
|
||||
.onSuccess(v -> rc.response().end())
|
||||
.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()
|
||||
@@ -257,17 +446,36 @@ public class MainVerticle extends AbstractVerticle {
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
router.get("/api/admin/restaurants/:id/check").handler(rc -> {
|
||||
int id = Integer.parseInt(rc.pathParam("id"));
|
||||
restaurantService.findById(id)
|
||||
.onSuccess(rest -> {
|
||||
if (rest == null) {
|
||||
rc.response().setStatusCode(404).end();
|
||||
} else {
|
||||
IikoOlapClient iiko = new IikoOlapClient(vertx, rest);
|
||||
|
||||
iiko.checkConnection()
|
||||
.onSuccess(res -> rc.response().putHeader("Content-Type", "application/json").end(res.encode()))
|
||||
.onFailure(err -> rc.response().putHeader("Content-Type", "application/json").end(
|
||||
new JsonObject().put("success", false).put("error", err.getMessage()).encode()));
|
||||
}
|
||||
})
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
router.post("/api/admin/restaurants").handler(rc -> {
|
||||
JsonObject body = rc.body().asJsonObject();
|
||||
String name = body.getString("name");
|
||||
String login = body.getString("login");
|
||||
String password = body.getString("password");
|
||||
String host = body.getString("host");
|
||||
boolean https = body.getBoolean("https", false);
|
||||
if (name == null || login == null || password == null || host == null) {
|
||||
rc.response().setStatusCode(400).end("Missing fields");
|
||||
return;
|
||||
}
|
||||
restaurantService.createRestaurant(name, login, password, host)
|
||||
restaurantService.createRestaurant(name, login, password, host, https)
|
||||
.onSuccess(v -> rc.response().setStatusCode(201).end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
@@ -279,11 +487,12 @@ public class MainVerticle extends AbstractVerticle {
|
||||
String login = body.getString("login");
|
||||
String password = body.getString("password");
|
||||
String host = body.getString("host");
|
||||
boolean https = body.getBoolean("https", false);
|
||||
if (name == null || login == null || host == null) {
|
||||
rc.response().setStatusCode(400).end("Missing required fields");
|
||||
return;
|
||||
}
|
||||
restaurantService.updateRestaurant(id, name, login, password, host)
|
||||
restaurantService.updateRestaurant(id, name, login, password, host, https)
|
||||
.onSuccess(v -> rc.response().end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
@@ -295,28 +504,25 @@ public class MainVerticle extends AbstractVerticle {
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
// Получение всех настроек
|
||||
router.get("/api/settings").handler(rc -> {
|
||||
settingsService.getAll()
|
||||
settingsService.getPublicSettings()
|
||||
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
.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>
|
||||
@@ -326,17 +532,140 @@ 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 -> {
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
package su.xserver.iikocon;
|
||||
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.http.HttpMethod;
|
||||
import io.vertx.core.json.Json;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.Router;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import io.vertx.ext.web.client.WebClient;
|
||||
import io.vertx.ext.web.client.WebClientOptions;
|
||||
import io.vertx.ext.web.codec.BodyCodec;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
public class ProxyVerticle extends AbstractVerticle {
|
||||
|
||||
private WebClient webClient;
|
||||
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
webClient = WebClient.create(vertx, new WebClientOptions()
|
||||
.setSsl(true)
|
||||
.setTrustAll(true)
|
||||
.setVerifyHost(false));
|
||||
|
||||
Router router = Router.router(vertx);
|
||||
router.post("/api/proxy").handler(this::handlePost);
|
||||
router.get("/api/proxy").handler(this::handleGet);
|
||||
|
||||
int port = 8080;
|
||||
vertx.createHttpServer()
|
||||
.requestHandler(router)
|
||||
.listen(port).onComplete(http -> {
|
||||
if (http.succeeded()) {
|
||||
System.out.println("Proxy server started on port " + port);
|
||||
startPromise.complete();
|
||||
} else {
|
||||
startPromise.fail(http.cause());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handlePost(RoutingContext ctx) {
|
||||
String apiServer = System.getenv("IIKO_API_SERVER");
|
||||
String apiLogin = System.getenv("IIKO_API_LOGIN");
|
||||
String apiPass = System.getenv("IIKO_API_PASS");
|
||||
String externalEndpoint = System.getenv("IIKO_API_ENDPOINT");
|
||||
if (externalEndpoint == null || externalEndpoint.isBlank()) {
|
||||
externalEndpoint = "/your-endpoint";
|
||||
}
|
||||
|
||||
if (apiServer == null || apiLogin == null || apiPass == null) {
|
||||
fail(ctx, 500, "Missing required environment variables: IIKO_API_SERVER, IIKO_API_LOGIN, IIKO_API_PASS");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject body = ctx.body().asJsonObject();
|
||||
if (body == null) {
|
||||
fail(ctx, 400, "Request body must be JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
String signature = sha1(apiPass);
|
||||
String authUrl = "https://" + apiServer + ":443/resto/api/auth?login=" + apiLogin + "&pass=" + signature;
|
||||
String finalExternalEndpoint = externalEndpoint;
|
||||
webClient.getAbs(authUrl)
|
||||
.as(BodyCodec.string())
|
||||
.send()
|
||||
.onSuccess(authResp -> {
|
||||
if (authResp.statusCode() != 200) {
|
||||
fail(ctx, authResp.statusCode(), "Authentication failed: " + authResp.statusMessage());
|
||||
return;
|
||||
}
|
||||
String token = authResp.body();
|
||||
String targetUrl = "https://" + apiServer + finalExternalEndpoint;
|
||||
webClient.request(HttpMethod.POST, targetUrl)
|
||||
.putHeader("Content-Type", "application/json")
|
||||
.as(BodyCodec.jsonObject())
|
||||
.sendJsonObject(body)
|
||||
.onSuccess(apiResp -> {
|
||||
webClient.getAbs("https://" + apiServer + ":443/resto/api/logout?key=" + token)
|
||||
.send()
|
||||
.onFailure(err -> System.err.println("Logout failed: " + err.getMessage()));
|
||||
if (apiResp.statusCode() == 200) {
|
||||
ctx.response().setStatusCode(200).end(apiResp.body().encode());
|
||||
} else {
|
||||
fail(ctx, apiResp.statusCode(), "External API error: " + apiResp.statusMessage());
|
||||
}
|
||||
})
|
||||
.onFailure(err -> fail(ctx, 500, "Request to external API failed: " + err.getMessage()));
|
||||
})
|
||||
.onFailure(err -> fail(ctx, 500, "Auth request failed: " + err.getMessage()));
|
||||
}
|
||||
|
||||
private void handleGet(RoutingContext ctx) {
|
||||
String presetId = ctx.queryParam("presetId").stream().findFirst().orElse(null);
|
||||
String dateFrom = ctx.queryParam("dateFrom").stream().findFirst().orElse(null);
|
||||
String dateTo = ctx.queryParam("dateTo").stream().findFirst().orElse(null);
|
||||
String server = ctx.queryParam("server").stream().findFirst().orElse(null);
|
||||
String password = ctx.queryParam("password").stream().findFirst().orElse(null);
|
||||
String login = ctx.queryParam("login").stream().findFirst().orElse(null);
|
||||
String type = ctx.queryParam("type").stream().findFirst().orElse(null);
|
||||
String rootType = ctx.queryParam("rootType").stream().findFirst().orElse(null);
|
||||
|
||||
if (server == null || login == null || password == null) {
|
||||
fail(ctx, 400, "Missing required parameters: server, login, password");
|
||||
return;
|
||||
}
|
||||
|
||||
String signature = sha1(password);
|
||||
String authUrl = "https://" + server + ":443/resto/api/auth?login=" + login + "&pass=" + signature;
|
||||
webClient.getAbs(authUrl)
|
||||
.as(BodyCodec.string())
|
||||
.send()
|
||||
.onSuccess(authResp -> {
|
||||
if (authResp.statusCode() != 200) {
|
||||
fail(ctx, authResp.statusCode(), "Authentication failed: " + authResp.statusMessage());
|
||||
return;
|
||||
}
|
||||
String token = authResp.body();
|
||||
String dataUrl;
|
||||
if ("entity".equals(type)) {
|
||||
dataUrl = "https://" + server + "/resto/api/v2/entities/list?key=" + token;
|
||||
if (rootType != null && !rootType.isBlank()) {
|
||||
dataUrl += "&rootType=" + rootType;
|
||||
}
|
||||
} else {
|
||||
if (presetId == null || dateFrom == null || dateTo == null) {
|
||||
fail(ctx, 400, "Missing presetId, dateFrom or dateTo for report request");
|
||||
return;
|
||||
}
|
||||
dataUrl = "https://" + server + "/resto/api/v2/reports/olap/byPresetId/" + presetId +
|
||||
"?key=" + token + "&dateFrom=" + dateFrom + "&dateTo=" + dateTo;
|
||||
}
|
||||
System.out.println("URL: " + dataUrl);
|
||||
webClient.getAbs(dataUrl)
|
||||
.as(BodyCodec.jsonObject())
|
||||
.send()
|
||||
.onSuccess(dataResp -> {
|
||||
// logout (fire and forget)
|
||||
webClient.getAbs("https://" + server + ":443/resto/api/logout?key=" + token)
|
||||
.send()
|
||||
.onFailure(err -> System.err.println("Logout failed: " + err.getMessage()));
|
||||
if (dataResp.statusCode() == 200) {
|
||||
JsonObject responseBody = dataResp.body();
|
||||
if ("entity".equals(type)) {
|
||||
ctx.response().setStatusCode(200).end(responseBody.encode());
|
||||
} else {
|
||||
Object data = responseBody.getValue("data");
|
||||
if (data == null) {
|
||||
ctx.response().setStatusCode(200).end(responseBody.encode());
|
||||
} else {
|
||||
// data может быть массивом, объектом или другим типом
|
||||
ctx.response().setStatusCode(200).end(Json.encode(data));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fail(ctx, dataResp.statusCode(), "External API error: " + dataResp.statusMessage());
|
||||
}
|
||||
})
|
||||
.onFailure(err -> fail(ctx, 500, "Data request failed: " + err.getMessage()));
|
||||
})
|
||||
.onFailure(err -> fail(ctx, 500, "Auth request failed: " + err.getMessage()));
|
||||
}
|
||||
|
||||
private String sha1(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
byte[] digest = md.digest(input.getBytes());
|
||||
return HexFormat.of().formatHex(digest).toLowerCase();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void fail(RoutingContext ctx, int status, String message) {
|
||||
System.err.println("Error: " + message);
|
||||
ctx.response().setStatusCode(status).end(new JsonObject().put("error", message).encode());
|
||||
}
|
||||
}
|
||||
|
||||
// > GET /api/proxy?server=folk-amber-co.iiko.it&login=4444&password=4444&presetId=7ddc40c3-9d5f-408f-aa1e-652964b36c6c&dateFrom=2026-04-10&dateTo=2026-04-17 HTTP/1.1
|
||||
// > Host: localhost:8080
|
||||
// > access-token: ddb4ab653b9194ec1ea5448cee2a8a26282b0866c1d4a86e98e9b0f84bc91944
|
||||
// > User-Agent: v2raytun/ios
|
||||
// > X-App-Version: 2.4.3
|
||||
// > X-Device-Model: iPhone 11 Pro
|
||||
// > X-Device-OS: iOS
|
||||
// > X-HWID: HHS8JDJN-F2EB-HFBS-KMWX-234FA7B95JSC
|
||||
// > X-Ver-OS: 26.0
|
||||
// > Accept: */*
|
||||
@@ -1,134 +0,0 @@
|
||||
package su.xserver.iikocon;
|
||||
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.sqlclient.Pool;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import io.vertx.sqlclient.templates.SqlTemplate;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class RestaurantService {
|
||||
private final Pool pool;
|
||||
|
||||
public RestaurantService(Pool pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
public Future<Void> initDatabase() {
|
||||
String createTable = """
|
||||
CREATE TABLE IF NOT EXISTS restaurants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
login VARCHAR(255) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
""";
|
||||
|
||||
return pool.query(createTable).execute().mapEmpty();
|
||||
}
|
||||
|
||||
public Future<Long> countRestaurant() {
|
||||
return pool.query("SELECT COUNT(*) AS cnt FROM restaurants")
|
||||
.execute()
|
||||
.map(rows -> rows.iterator().next().getLong("cnt"));
|
||||
}
|
||||
|
||||
public Future<Void> createRestaurant(String name, String login, String password, String host) {
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("name", name);
|
||||
params.put("login", login);
|
||||
params.put("password", password);
|
||||
params.put("host", host);
|
||||
|
||||
return SqlTemplate.forUpdate(pool,
|
||||
"INSERT INTO restaurants (name, login, password, host) VALUES (#{name}, #{login}, #{password}, #{host})")
|
||||
.execute(params)
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
public Future<JsonObject> findByName(String name) {
|
||||
return SqlTemplate.forQuery(pool,
|
||||
"SELECT id, name, login, password, created, updated, host FROM restaurants WHERE name = #{name}")
|
||||
.mapTo(row -> new JsonObject()
|
||||
.put("id", row.getInteger("id"))
|
||||
.put("name", row.getString("name"))
|
||||
.put("login", row.getString("login"))
|
||||
.put("password", row.getString("password"))
|
||||
.put("created", row.getLocalDateTime("created") != null ?
|
||||
row.getLocalDateTime("created").toString() : null)
|
||||
.put("updated", row.getLocalDateTime("updated") != null ?
|
||||
row.getLocalDateTime("updated").toString() : null)
|
||||
.put("host", row.getString("host")))
|
||||
.execute(Collections.singletonMap("name", name))
|
||||
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
|
||||
}
|
||||
|
||||
public Future<JsonArray> getAllRestaurants() {
|
||||
return pool.query("SELECT id, name, login, created, updated, host FROM restaurants ORDER BY id")
|
||||
.execute()
|
||||
.map(rows -> {
|
||||
JsonArray array = new JsonArray();
|
||||
for (Row row : rows) {
|
||||
array.add(new JsonObject()
|
||||
.put("id", row.getInteger("id"))
|
||||
.put("login", row.getString("login"))
|
||||
.put("name", row.getString("name"))
|
||||
.put("created", row.getLocalDateTime("created") != null ?
|
||||
row.getLocalDateTime("created").toString() : null)
|
||||
.put("updated", row.getLocalDateTime("updated") != null ?
|
||||
row.getLocalDateTime("updated").toString() : null)
|
||||
.put("host", row.getString("host")));
|
||||
}
|
||||
return array;
|
||||
});
|
||||
}
|
||||
|
||||
public Future<JsonObject> findById(int id) {
|
||||
return SqlTemplate.forQuery(pool,
|
||||
"SELECT id, name, login, password, host, created, updated FROM restaurants WHERE id = #{id}")
|
||||
.mapTo(row -> new JsonObject()
|
||||
.put("id", row.getInteger("id"))
|
||||
.put("name", row.getString("name"))
|
||||
.put("login", row.getString("login"))
|
||||
.put("password", row.getString("password"))
|
||||
.put("host", row.getString("host"))
|
||||
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
|
||||
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null))
|
||||
.execute(Collections.singletonMap("id", id))
|
||||
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
|
||||
}
|
||||
|
||||
public Future<Void> updateRestaurant(int id, String name, String login, String password, String host) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("id", id);
|
||||
params.put("name", name);
|
||||
params.put("login", login);
|
||||
params.put("host", host);
|
||||
|
||||
String sql;
|
||||
if (password != null && !password.isEmpty()) {
|
||||
params.put("password", password);
|
||||
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, password = #{password}, host = #{host} WHERE id = #{id}";
|
||||
} else {
|
||||
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, host = #{host} WHERE id = #{id}";
|
||||
}
|
||||
|
||||
return SqlTemplate.forUpdate(pool, sql)
|
||||
.execute(params)
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
public Future<Void> deleteRestaurant(int id) {
|
||||
return SqlTemplate.forUpdate(pool, "DELETE FROM restaurants WHERE id = #{id}")
|
||||
.execute(Collections.singletonMap("id", id))
|
||||
.mapEmpty();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user