Compare commits

..

58 Commits

Author SHA1 Message Date
98f854c1a3 fix 2026-05-09 14:33:00 +03:00
f3a0750891 fix 2026-05-09 14:32:25 +03:00
f43314b122 fix 2026-05-09 14:06:35 +03:00
57b5a9fa12 fix 2026-05-09 14:06:21 +03:00
1e7587e11b feat: implement version display with commit hash and date 2026-05-09 14:05:25 +03:00
debf1b165f fix: update sql code & download prettily JSON 2026-05-09 13:08:35 +03:00
f3b407e1ed add: translation of OlapConstructor.vue 2026-05-09 13:02:40 +03:00
5382488a82 fix: economical JSON 2026-05-08 15:22:25 +03:00
031757353d add: translation of OlapQueries.vue
fix: optimization of translation
2026-05-08 15:17:17 +03:00
8f86dc5831 fix: micro fixes 2026-05-08 14:03:28 +03:00
1531215b43 fix: we no longer use sql-client-templates 2026-05-08 13:55:46 +03:00
fa0b2518af updated dependencies 2026-05-07 23:16:10 +03:00
4b4486e3ef fix 2026-05-07 19:19:48 +03:00
651d2a5d0c fix 2026-05-07 18:42:25 +03:00
a1bd5a2b5f ref 2026-05-07 18:33:51 +03:00
71cae60b90 up 2026-05-07 18:31:53 +03:00
0b20b77690 fix 2026-05-07 18:17:00 +03:00
1a5b10b129 up 2026-05-07 18:05:17 +03:00
59e283945c up 2026-05-07 17:36:03 +03:00
096fb1a3e2 up... 2026-05-07 17:01:02 +03:00
c108ad4a5a up OLAPConstructor.vue 2026-05-07 15:45:47 +03:00
4e60a78fbd up build.gradle.kts 2026-05-07 02:22:42 +03:00
08368afbc5 fix 2026-05-07 01:35:07 +03:00
a851df0494 fix 2026-05-07 01:28:08 +03:00
b203d6b7d8 fix 2026-05-07 00:30:53 +03:00
7ae323a0c5 fix 2026-05-07 00:02:00 +03:00
8386be18ba fix 2026-05-07 00:01:28 +03:00
ec6c52c79d fix 2026-05-06 23:36:57 +03:00
0e6103f138 R.I.P me........ 2026-05-06 21:15:12 +03:00
a406af54bd fix 2026-05-04 15:10:26 +03:00
1ca4c90b88 up 2026-05-04 15:08:03 +03:00
a61c527ef9 up 2026-05-04 13:22:25 +03:00
f39d9ff11e up 2026-05-01 19:11:08 +03:00
c801783779 up, add OLAP columns page 2026-05-01 19:04:18 +03:00
50d4ea10c6 fix 2026-04-29 01:10:35 +03:00
0836f8e9e9 up 2026-04-29 01:03:11 +03:00
e7f135e8c1 up 2026-04-28 19:26:24 +03:00
664092f415 fix 2026-04-28 15:07:14 +03:00
38cc75a688 add Rate Limiter & fix 2026-04-28 15:00:21 +03:00
7a60bb15fe fix 2026-04-27 16:11:54 +03:00
43b57bdb0f up package.json 2026-04-27 15:48:22 +03:00
05076eb367 fix and refactor code 2026-04-27 15:45:06 +03:00
316d06b1d2 updated dependencies 2026-04-27 14:29:59 +03:00
a68f02bab4 updated dependencies 2026-04-27 14:24:47 +03:00
aad6ba3747 updated dependencies 2026-04-27 14:20:56 +03:00
1c7e05f6a3 fix frontend 2026-04-24 01:44:03 +03:00
ff46a37956 add restaurants check connection 2026-04-24 00:55:20 +03:00
c47dad2af8 up 2026-04-21 04:20:14 +03:00
82a932dd2b up frontend 2026-04-21 03:57:59 +03:00
b9d1afad42 refactor 2026-04-21 01:28:02 +03:00
1d8a436106 up test 2026-04-21 01:08:46 +03:00
b7875bb623 up 2026-04-20 20:32:59 +03:00
fc96a95335 add user privileges & add translations 2026-04-20 19:12:27 +03:00
f16a830eb2 up 2026-04-20 15:57:50 +03:00
f3e105bbc8 fix 2026-04-20 14:00:04 +03:00
ec0671c5e8 up 2026-04-20 13:42:41 +03:00
fd3cbb019f add nginx.conf 2026-04-18 13:46:55 +03:00
c47542bef3 up 2026-04-18 13:36:21 +03:00
122 changed files with 7634 additions and 1145 deletions

2
.gitignore vendored
View File

@@ -185,3 +185,5 @@ nbdist/
!.vscode/extensions.json
/build/
/logs/
/src/main/resources/version.properties
/src/main/resources/webroot/

View File

@@ -1,2 +1,5 @@
# iiko-connector
* `Числовые``Агрегация`
* `Категории``Группировка {ROW / COLUMN}`
* `Фильтры``Фильтрация`

View File

@@ -1,15 +1,19 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.gradle.api.tasks.testing.logging.TestLogEvent.*
import java.nio.file.Files
import java.nio.file.Paths
import java.time.Instant
import java.time.format.DateTimeFormatter
plugins {
java
application
id("com.gradleup.shadow") version "9.2.2"
id("com.gradleup.shadow") version "9.4.1"
id("com.github.node-gradle.node") version "7.1.0"
}
node {
version.set("22.19.0") // версия Node.js
version.set("24.15.0") // версия Node.js
npmVersion.set("11.12.1") // версия npm
download.set(true) // автоматически скачать Node.js
workDir.set(file("${project.projectDir}/.gradle/nodejs"))
@@ -18,13 +22,13 @@ node {
}
group = "com.example"
version = "1.0.0-SNAPSHOT"
version = "1.0.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")
}

View File

@@ -34,11 +34,13 @@ services:
environment:
PMA_HOST: iiko-db
PMA_PORT: 3306
PMA_USER: root
PMA_PASSWORD: DVjXT_kew508
UPLOAD_LIMIT: 10M
# PMA_ABSOLUTE_URI: https://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

View File

@@ -1,8 +0,0 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

View File

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

View File

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

View File

@@ -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,37 +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 // или можно сделать красивое отображение
}
} catch (e) {
userName.value = 'User'
}
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'
})
const userInitials = computed(() => {
return (userName.value[0] || 'U').toUpperCase()
})
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)
}
} else {
// Для неавторизованных просто сохраняем в localStorage
locale.value = newLang
localStorage.setItem('locale', newLang)
}
}
</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>

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

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

View 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": "Не удалось удалить подключение к БД."
}
}

View File

@@ -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')
})

View File

@@ -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,17 +44,42 @@ const routes = [
{
path: '/users',
component: Users,
meta: { requiresAuth: true, title: 'Users' }
meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' }
},
{
path: '/restaurants',
component: Restaurants,
meta: { requiresAuth: true, title: 'Restaurants' }
},
{
path: '/olap/columns',
component: OlapColumns,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Olap Columns' }
},
{
path: '/olap/queries',
component: OlapQueries,
meta: { requiresAuth: true, title: 'OLAP Queries' }
},
{
path: '/olap/constructor/:id?',
component: OlapConstructor,
meta: { requiresAuth: true, title: 'OLAP Constructor' }
},
{
path: '/database-connections',
component: DbConnections,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Database Connections' }
},
{
path: '/settings',
component: AdminSettings,
meta: { requiresAuth: true, title: 'Settings' }
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
},
{
path: '/profile',
component: Profile,
meta: { requiresAuth: true, title: 'Profile' }
},
{
path: '/:pathMatch(.*)*',
@@ -44,54 +89,49 @@ const routes = [
}
]
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) {
if (requiresAuth && !userStore.id) {
next('/login')
} else {
next()
}
} catch {
next('/login')
}
} else if (requiresAdmin && userStore.role !== 'admin') {
next('/dashboard')
} else {
next()
}

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

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

View File

@@ -0,0 +1,62 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface BuildVersion {
version: string
commitHash: string
buildTime: string // ISO строка, например "2025-04-03T12:34:56Z"
}
export const useVersionStore = defineStore('version', () => {
const version = ref<BuildVersion | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchVersion() {
if (version.value) return
loading.value = true
try {
const res = await fetch('/api/build-info')
if (!res.ok) throw new Error('Failed to fetch version')
const data = await res.json()
version.value = {
version: data.version || '0.0.0',
commitHash: data.commitHash || 'unknown',
buildTime: data.buildTime || ''
}
} catch (err: any) {
console.error(err)
error.value = err.message
version.value = { version: 'dev', commitHash: 'unknown', buildTime: '' }
} finally {
loading.value = false
}
}
// Отформатированная дата сборки (только дата, без времени)
const buildDateFormatted = computed(() => {
if (!version.value?.buildTime) return ''
const date = new Date(version.value.buildTime)
if (isNaN(date.getTime())) return ''
// Формат YYYY-MM-DD (универсальный, без локализации)
return date.toISOString().split('T')[0]
})
// Полная строка версии: "Версия: 1.2.3 (build abc1234 от 2025-04-03)"
// Принимает функцию перевода для слова "от"/"from"
const getFormattedVersion = (t: (key: string) => string) => {
if (!version.value) return t('common.version') + '...'
const { version: ver, commitHash } = version.value
const datePart = buildDateFormatted.value ? ` ${t('common.versionFrom')} ${buildDateFormatted.value}` : ''
return `${t('common.version')}: ${ver} (build ${commitHash}${datePart})`
}
const getShortVersion = () => {
if (!version.value) return '...'
const { version: ver, commitHash } = version.value
const datePart = buildDateFormatted.value ? ` ${buildDateFormatted.value}` : ''
return `${ver} (build ${commitHash}${datePart})`
}
return { version, loading, error, fetchVersion, buildDateFormatted, getFormattedVersion, getShortVersion }
})

View File

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

View File

@@ -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,110 +138,112 @@
<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>
<span class="text-sm text-gray-500">{{ service.latency }}ms</span>
<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>
</div>
<div v-if="recentRestaurants.length === 0" class="text-center text-gray-500 py-8">{{ t('dashboard.noRestaurants') }}</div>
</div>
</div>
</div>
</AppLayout>
</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>

View File

@@ -0,0 +1,384 @@
<template>
<AppLayout>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ t('dbConnections.pageName') }}</h1>
<button @click="openModal('create')" class="btn-primary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('dbConnections.add') }}
</button>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.type') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.host') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.port') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.database') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.user') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="conn in connections" :key="conn.id" class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ conn.id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ conn.name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span :class="getTypeBadgeClass(conn.type)" class="px-2 py-1 rounded-full text-xs font-medium">
{{ getTypeLabel(conn.type) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.host }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.port }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.database }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.user }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(conn.created) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-3">
<button @click="testConnection(conn)" :disabled="conn.testing" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50" :title="t('dbConnections.test')">
<svg v-if="!conn.testing" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
<button @click="openModal('edit', conn)" class="text-blue-600 hover:text-blue-800 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="confirmDelete(conn.id)" class="text-red-600 hover:text-red-800 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<span v-if="conn.testResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
{{ conn.testResult }}
</span>
</div>
</td>
</tr>
<tr v-if="connections.length === 0">
<td colspan="9" class="px-6 py-12 text-center text-gray-500">{{ t('dbConnections.noConnections') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модальное окно создания/редактирования -->
<Transition name="fade">
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
<div class="flex justify-between items-center p-6 border-b">
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="submitConnection" class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }} *</label>
<input v-model="form.name" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.type') }} *</label>
<select v-model="form.type" required class="input-field">
<option value="mysql">MySQL</option>
<option value="postgres">PostgreSQL</option>
<option value="clickhouse">ClickHouse</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.host') }} *</label>
<input v-model="form.host" type="text" required class="input-field" placeholder="localhost or IP" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.port') }} *</label>
<input v-model="form.port" type="number" required class="input-field" placeholder="3306, 5432, 8123..." />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.database') }} *</label>
<input v-model="form.database" type="text" required class="input-field" placeholder="database name" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.user') }} *</label>
<input v-model="form.user" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.password') }}</label>
<input
v-model="form.password"
:required="modalMode === 'create'"
type="password"
class="input-field"
autocomplete="new-password"
/>
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" @click="closeModal" class="btn-secondary">{{ t('app.cancel') }}</button>
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
</div>
</form>
</div>
</div>
</div>
</Transition>
<!-- Модальное окно подтверждения удаления -->
<Transition name="fade">
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
<div class="p-6 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('dbConnections.delete') }}</h3>
<p class="text-sm text-gray-500 mb-6">{{ t('dbConnections.deleteConfirmation') }}</p>
<div class="flex justify-center space-x-3">
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="deleteConnection(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
type Connection = {
id: number;
name: string;
type: 'mysql' | 'postgres' | 'clickhouse';
host: string;
port: number;
database: string;
user: string;
created: string;
testing?: boolean;
testResult?: string | null;
};
const connections = ref<Connection[]>([]);
const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create');
const form = ref({
id: null as number | null,
name: '',
type: 'mysql' as 'mysql' | 'postgres' | 'clickhouse',
host: '',
port: 3306,
database: '',
user: '',
password: ''
});
const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null as number | null });
// Загрузка списка подключений
async function loadConnections() {
try {
const res = await fetch('/api/admin/database-connections');
if (!res.ok) throw new Error();
const data = await res.json();
connections.value = data.map((c: any) => ({
...c,
testing: false,
testResult: null
}));
} catch (e) {
showNotification('dbConnections.loadError', 'error');
}
}
function formatDate(dateStr: string) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
// Тестирование соединения
async function testConnection(conn: Connection) {
conn.testing = true;
conn.testResult = null;
try {
const response = await fetch(`/api/admin/database-connections/${conn.id}/test`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
conn.testResult = `${data.latency_ms} ms`;
showNotification('dbConnections.testSuccess', 'success', { latency: data.latency_ms });
} else {
const errorText = data.error || t('dbConnections.testUnknownError');
showNotification('dbConnections.testError', 'error', { error: errorText });
}
} catch (error: any) {
showNotification('dbConnections.testNetworkError', 'error', { error: error.message });
} finally {
conn.testing = false;
}
}
// Вспомогательные функции для отображения типа
function getTypeLabel(type: string) {
const labels: Record<string, string> = {
mysql: 'MySQL',
postgres: 'PostgreSQL',
clickhouse: 'ClickHouse'
};
return labels[type] || type;
}
function getTypeBadgeClass(type: string) {
const classes: Record<string, string> = {
mysql: 'bg-blue-100 text-blue-800',
postgres: 'bg-indigo-100 text-indigo-800',
clickhouse: 'bg-amber-100 text-amber-800'
};
return classes[type] || 'bg-gray-100 text-gray-800';
}
function openModal(mode: 'create' | 'edit', conn: Connection | null = null) {
modalMode.value = mode;
if (mode === 'create') {
form.value = {
id: null,
name: '',
type: 'mysql',
host: '',
port: 3306,
database: '',
user: '',
password: ''
};
modalTitle.value = t('dbConnections.add');
} else if (conn) {
form.value = {
id: conn.id,
name: conn.name,
type: conn.type,
host: conn.host,
port: conn.port,
database: conn.database,
user: conn.user,
password: ''
};
modalTitle.value = t('dbConnections.edit');
}
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
}
async function submitConnection() {
if (modalMode.value === 'create' && !form.value.password) {
showNotification('dbConnections.passwordRequired', 'error');
return;
}
try {
const payload: any = {
name: form.value.name,
type: form.value.type,
host: form.value.host,
port: form.value.port,
database: form.value.database,
user: form.value.user,
};
if (form.value.password) {
payload.password = form.value.password;
}
if (modalMode.value === 'create') {
const res = await fetch('/api/admin/database-connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error();
showNotification('dbConnections.createSuccess', 'success');
} else {
const res = await fetch(`/api/admin/database-connections/${form.value.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error();
showNotification('dbConnections.updateSuccess', 'success');
}
await loadConnections();
closeModal();
} catch (e) {
showNotification(modalMode.value === 'create' ? 'dbConnections.createError' : 'dbConnections.updateError', 'error');
}
}
function confirmDelete(id: number) {
deleteConfirm.value = { show: true, id };
}
async function deleteConnection(id: number) {
try {
const res = await fetch(`/api/admin/database-connections/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification('dbConnections.deleteSuccess', 'success');
await loadConnections();
} catch (e) {
showNotification('dbConnections.deleteError', 'error');
} finally {
deleteConfirm.value.show = false;
}
}
onMounted(loadConnections);
</script>
<style scoped>
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
<template>
<AppLayout>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ t('olapQueries.title') }}</h1>
<router-link to="/olap/constructor" class="btn-primary">+ {{ t('olapQueries.createButton') }}</router-link>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.active') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapQueries.lastRun') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapQueries.result') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olapQueries.connection') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('app.restaurants') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="q in queries" :key="q.id" class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm">{{ q.id }}</td>
<td class="px-6 py-4 text-sm font-medium">{{ q.name }}</td>
<td class="px-6 py-4 text-sm">
<span :class="q.active ? 'text-green-600' : 'text-red-600'">
{{ q.active ? t('common.yes') : t('common.no') }}
</span>
</td>
<td class="px-6 py-4 text-sm">{{ q.lastRun ? formatDate(q.lastRun) : '—' }}</td>
<td class="px-6 py-4 text-sm">
<span v-if="q.lastRunSuccess === null"></span>
<span v-else-if="q.lastRunSuccess" class="text-green-600">{{ t('olapQueries.success') }}</span>
<span v-else class="text-red-600">{{ t('olapQueries.error') }}</span>
</td>
<td class="px-6 py-4 text-sm">{{ q.dbConnectionName }}</td>
<td class="px-6 py-4 text-sm">{{ q.restaurants }}</td>
<td class="px-6 py-4 text-sm">{{ formatDate(q.created) }}</td>
<td class="px-6 py-4 text-right space-x-2">
<router-link :to="`/olap/constructor/${q.id}`" class="text-blue-600 hover:text-blue-800">
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</router-link>
<button @click="confirmDelete(q.id)" class="text-red-600 hover:text-red-800">
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
<tr v-if="queries.length === 0">
<td colspan="6" class="px-6 py-12 text-center text-gray-500">{{ t('olapQueries.noQueries') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модальное окно удаления с улучшенной стилизацией -->
<Teleport to="body">
<Transition name="fade">
<div v-if="deleteModal.show" class="fixed inset-0 z-[9999] overflow-y-auto" @click.self="deleteModal.show = false">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
<div class="p-6 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('olapQueries.deleteQueriesTitle') }}</h3>
<p class="text-sm text-gray-500 mb-6">{{ t('olapQueries.deleteQueriesMessage') }}</p>
<div class="flex justify-center space-x-3">
<button @click="deleteModal.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="deleteQuery" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/Layout/AppLayout.vue'
import { useNotification } from '@/composables/useNotification'
const { t } = useI18n()
const { showNotification } = useNotification()
const queries = ref([])
const deleteModal = ref({ show: false, id: null as number | null })
async function loadQueries() {
try {
const res = await fetch('/api/olap/queries')
if (!res.ok) throw new Error()
queries.value = await res.json()
} catch (e) {
showNotification('olapQueries.loadError', 'error')
}
}
function formatDate(dateStr: string | null) {
return dateStr ? new Date(dateStr).toLocaleString() : '-'
}
function confirmDelete(id: number) {
deleteModal.value = { show: true, id }
}
async function deleteQuery() {
const id = deleteModal.value.id
if (!id) return
try {
const res = await fetch(`/api/olap/queries/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error()
showNotification('olapQueries.deleteSuccess', 'success')
await loadQueries()
} catch (e) {
showNotification('olapQueries.deleteError', 'error')
} finally {
deleteModal.value.show = false
}
}
onMounted(loadQueries)
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

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

View File

@@ -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">
<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">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>
<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">
<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>
<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.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 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>
<!-- 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" />
<!-- Модалка создания/редактирования (без изменений) -->
<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>
<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" />
<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 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>
<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="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 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 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>
<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() {
try {
const res = await fetch('/api/admin/restaurants');
restaurants.value = await res.json();
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>

View File

@@ -1,110 +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">
<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">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>
<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>
<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>
<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">
<button
v-if="user.id !== currentUserId"
@click="toggleActive(user)"
:class="user.active ? 'text-green-600' : 'text-red-600'"
>
{{ user.active ? 'Active' : 'Inactive' }}
</button>
<span v-else class="text-gray-400 text-sm">(You)</span>
<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">{{ 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>
<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 !== currentUserId"
@click="deleteUser(user.id)"
class="text-red-600"
v-if="user.id !== userStore.id"
@click="confirmDelete(user.id)"
class="text-red-600 hover:text-red-800 transition-colors"
>
Delete
<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 -->
<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" />
<!-- 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>
<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" />
<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 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>
<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 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>
<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 currentUserId = ref<number | null>(null);
const { t } = useI18n();
const { showNotification } = useNotification();
const userStore = useUserStore();
async function loadCurrentUser() {
try {
const res = await fetch('/api/admin/me');
if (res.ok) {
const data = await res.json();
currentUserId.value = data.id;
}
} catch (e) {
console.error('Failed to load current user', e);
}
}
const users = ref([]);
const users = ref<any[]>([]);
const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create');
const form = ref({ id: null, login: '', email: '', 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() {
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) {
@@ -113,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: '', email: '', 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, email: user.email, password: '' }; // добавлен email
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;
}
@@ -134,49 +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') {
if (!form.value.password) {
alert('Password is required');
return;
}
const res = await fetch('/api/admin/users', {
response = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Create failed');
if (!response.ok) throw new Error();
showNotification('users.createSuccess', 'success');
} else {
const res = 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(payload),
});
if (!res.ok) throw new Error('Update failed');
if (!response.ok) throw new Error();
showNotification('users.updateSuccess', 'success');
}
await loadUsers();
closeModal();
} catch (e) {
alert('Operation failed: ' + e.message);
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(async () => {
await loadCurrentUser();
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>

View File

@@ -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 or Email</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">
@@ -34,7 +34,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">
@@ -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,27 +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 {
// Пытаемся получить текст ошибки от сервера
const text = await res.text()
if (text && text.trim()) {
error.value = text
} else {
error.value = 'Invalid username or password'
}
error.value = text || t('login.invalidCredentials')
}
} catch (e) {
error.value = 'Network error. Please try again.'
error.value = t('login.networkError')
} finally {
loading.value = false
}

View File

@@ -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('')

View File

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

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"jsx": "preserve",
"jsxImportSource": "vue",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules"]
}

View File

@@ -3,6 +3,11 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': '/src',
},
},
server: {
proxy: {
'/api': 'http://localhost:8080' // для разработки

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

Binary file not shown.

Binary file not shown.

BIN
libs/asm-9.7.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/client-v2-0.9.8.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
libs/commons-io-2.20.0.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
libs/guava-33.4.6-jre.jar Normal file

Binary file not shown.

BIN
libs/httpclient5-5.4.4.jar Normal file

Binary file not shown.

BIN
libs/httpcore5-5.3.4.jar Normal file

Binary file not shown.

BIN
libs/httpcore5-h2-5.3.4.jar Normal file

Binary file not shown.

Binary file not shown.

BIN
libs/jdbc-v2-0.9.8.jar Normal file

Binary file not shown.

BIN
libs/lz4-java-1.10.4.jar Normal file

Binary file not shown.

Binary file not shown.

BIN
libs/postgresql-42.7.11.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.

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

View File

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

View File

@@ -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);
sessionStore = RedisSessionStore.create(vertx, redis.getRedis());
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionCookieName("admin.session")
.setCookieHttpOnlyFlag(true)
.setCookieSecureFlag(false)
.setSessionTimeout(3600000);
.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,18 +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;
}
// Создаём активного пользователя (active = true)
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()));
});
@@ -198,12 +384,13 @@ 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()));
});
@@ -226,7 +413,7 @@ 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) {
@@ -239,21 +426,6 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Получение текущего пользователя
router.get("/api/admin/me").handler(rc -> {
Integer userId = rc.session().get("userId");
if (userId != null) {
rc.response()
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("id", userId)
.put("login", rc.session().get("login"))
.encode());
} else {
rc.response().setStatusCode(401).end();
}
});
router.get("/api/admin/restaurants").handler(rc -> restaurantService.getAllRestaurants().onComplete(ar -> {
if (ar.succeeded()) {
rc.response()
@@ -274,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()));
});
@@ -296,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()));
});
@@ -312,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>
@@ -343,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 -> {

View File

@@ -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: */*

View File

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

View File

@@ -1,167 +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.Tuple;
import io.vertx.sqlclient.templates.SqlTemplate;
import org.mindrot.jbcrypt.BCrypt;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class UserService {
private final Pool pool;
public UserService(Pool pool) {
this.pool = pool;
}
public Future<Void> initDatabase() {
String createTable = """
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
login VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
active BOOLEAN DEFAULT FALSE,
ip VARCHAR(45),
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
""";
return pool.query(createTable).execute().mapEmpty();
}
public Future<Long> countUsers() {
return pool.query("SELECT COUNT(*) AS cnt FROM users")
.execute()
.map(rows -> rows.iterator().next().getLong("cnt"));
}
public Future<Void> createUser(String login, String email, String password, String ip, boolean active) {
String hash = BCrypt.hashpw(password, BCrypt.gensalt());
Map<String, Object> params = Map.of(
"login", login,
"email", email,
"password", hash,
"ip", ip,
"active", active
);
return SqlTemplate.forUpdate(pool,
"INSERT INTO users (login, email, password, ip, active) VALUES (#{login}, #{email}, #{password}, #{ip}, #{active})")
.execute(params)
.mapEmpty();
}
// Существующий метод оставляем, но он будет создавать неактивного пользователя (active = false)
public Future<Void> createUser(String login, String email, String password, String ip) {
return createUser(login, email, password, ip, false);
}
public Future<Void> setActive(int id, boolean active) {
return SqlTemplate.forUpdate(pool, "UPDATE users SET active = #{active} WHERE id = #{id}")
.execute(Map.of("id", id, "active", active)).mapEmpty();
}
public Future<JsonObject> findByLoginOrEmail(String loginOrEmail) {
String sql = "SELECT id, login, email, password, active, ip, created, updated FROM users WHERE login = ? OR email = ?";
return pool.preparedQuery(sql)
.execute(Tuple.of(loginOrEmail, loginOrEmail))
.map(rows -> {
if (rows.size() == 0) {
return null;
}
Row row = rows.iterator().next();
return toJson(row);
});
}
public Future<JsonObject> findByEmail(String email) {
return SqlTemplate.forQuery(pool, "SELECT id, login, email, password, active, ip, created, updated FROM users WHERE email = #{email}")
.mapTo(this::toJson)
.execute(Map.of("email", email))
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
}
public Future<JsonObject> findByLogin(String login) {
return SqlTemplate.forQuery(pool,
"SELECT id, login, password, created, updated, ip FROM users WHERE login = #{login}")
.mapTo(row -> new JsonObject()
.put("id", row.getInteger("id"))
.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("ip", row.getString("ip")))
.execute(Collections.singletonMap("login", login))
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
}
public Future<JsonArray> getAllUsers() {
return pool.query("SELECT id, login, email, active, ip, created, updated FROM users 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("email", row.getString("email"))
.put("active", row.getBoolean("active"))
.put("ip", row.getString("ip"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null));
}
return array;
});
}
public Future<Void> updateUser(int id, String login, String email, String password, String ip) {
Map<String, Object> params = new HashMap<>();
params.put("id", id);
params.put("login", login);
params.put("email", email);
params.put("ip", ip);
String sql;
if (password != null && !password.isEmpty()) {
String hash = BCrypt.hashpw(password, BCrypt.gensalt());
params.put("password", hash);
sql = "UPDATE users SET login = #{login}, email = #{email}, password = #{password}, ip = #{ip} WHERE id = #{id}";
} else {
sql = "UPDATE users SET login = #{login}, email = #{email}, ip = #{ip} WHERE id = #{id}";
}
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
}
public Future<Void> deleteUser(int id) {
return SqlTemplate.forUpdate(pool, "DELETE FROM users WHERE id = #{id}")
.execute(Collections.singletonMap("id", id))
.mapEmpty();
}
public boolean checkPassword(String plain, String hash) {
try {
return BCrypt.checkpw(plain, hash);
} catch (Exception e) {
return false;
}
}
private JsonObject toJson(Row row) {
return new JsonObject()
.put("id", row.getInteger("id"))
.put("login", row.getString("login"))
.put("email", row.getString("email"))
.put("password", row.getString("password")) // ← ДОБАВИТЬ ЭТУ СТРОКУ
.put("active", row.getBoolean("active"))
.put("ip", row.getString("ip"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null);
}
}

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