Compare commits

..

29 Commits

Author SHA1 Message Date
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
97 changed files with 4686 additions and 914 deletions

View File

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

View File

@@ -4,12 +4,12 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent.*
plugins { plugins {
java java
application 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" id("com.github.node-gradle.node") version "7.1.0"
} }
node { node {
version.set("22.19.0") // версия Node.js version.set("24.15.0") // версия Node.js
npmVersion.set("11.12.1") // версия npm npmVersion.set("11.12.1") // версия npm
download.set(true) // автоматически скачать Node.js download.set(true) // автоматически скачать Node.js
workDir.set(file("${project.projectDir}/.gradle/nodejs")) workDir.set(file("${project.projectDir}/.gradle/nodejs"))
@@ -24,7 +24,7 @@ repositories {
mavenCentral() mavenCentral()
} }
val vertxVersion = "5.0.10" val vertxVersion = "5.0.11"
val mainVerticleName = "su.xserver.iikocon.MainVerticle" val mainVerticleName = "su.xserver.iikocon.MainVerticle"
val launcherClassName = "io.vertx.launcher.application.VertxApplication" val launcherClassName = "io.vertx.launcher.application.VertxApplication"
@@ -37,6 +37,7 @@ dependencies {
implementation(platform("io.vertx:vertx-stack-depchain:$vertxVersion")) implementation(platform("io.vertx:vertx-stack-depchain:$vertxVersion"))
implementation("io.vertx:vertx-launcher-application") implementation("io.vertx:vertx-launcher-application")
implementation("io.vertx:vertx-web-client") implementation("io.vertx:vertx-web-client")
implementation("io.vertx:vertx-web-proxy")
implementation("io.vertx:vertx-config") implementation("io.vertx:vertx-config")
implementation("io.vertx:vertx-sql-client-templates") implementation("io.vertx:vertx-sql-client-templates")
implementation("io.vertx:vertx-health-check") implementation("io.vertx:vertx-health-check")
@@ -48,16 +49,25 @@ dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind") 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") 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") implementation("org.slf4j:slf4j-api:2.0.17")
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j2-impl // Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j2-impl
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.25.3") implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.25.4")
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core // Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
implementation("org.apache.logging.log4j:log4j-core:2.25.3") implementation("org.apache.logging.log4j:log4j-core:2.25.4")
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api // Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api
implementation("org.apache.logging.log4j:log4j-api:2.25.3") 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")
} }

View File

@@ -34,11 +34,13 @@ services:
environment: environment:
PMA_HOST: iiko-db PMA_HOST: iiko-db
PMA_PORT: 3306 PMA_PORT: 3306
PMA_USER: root
PMA_PASSWORD: DVjXT_kew508
UPLOAD_LIMIT: 10M UPLOAD_LIMIT: 10M
# PMA_ABSOLUTE_URI: https://phpmyadmin.dev.xserver.su/ PMA_ABSOLUTE_URI: https://iiko-app.dev.xserver.su/phpmyadmin/
TZ: Europe/Moscow TZ: Europe/Moscow
ports: # ports:
- "7102:80" # - "7102:80"
iiko-redis: iiko-redis:
image: redis:latest image: redis:latest
@@ -75,3 +77,9 @@ services:
REDIS__HOST: iiko-redis REDIS__HOST: iiko-redis
REDIS__PORT: 6379 REDIS__PORT: 6379
SERVER__PORT: 7104 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", "axios": "^1.15.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.31", "vue": "^3.5.31",
"vue-i18n": "^11.4.0",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
@@ -22,7 +23,7 @@
"postcss": "^8.5.9", "postcss": "^8.5.9",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^8.0.3", "vite": "^7.3.2",
"vite-plugin-vue-devtools": "^8.1.1" "vite-plugin-vue-devtools": "^8.1.1"
}, },
"engines": { "engines": {

View File

@@ -19,3 +19,20 @@
opacity: 0; opacity: 0;
} }
</style> </style>
<script setup lang="ts">
import { watch } from 'vue'
import { useSettingsStore } from '@/stores/settings'
const settings = useSettingsStore()
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 })
</script>

View File

@@ -1,100 +1,163 @@
<template> <template>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<!-- Sidebar --> <!-- 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"> <div class="flex flex-col h-full">
<!-- Logo --> <!-- Logo / Toggle Button -->
<div class="flex items-center h-16 px-6 border-b border-gray-200"> <div class="flex items-center h-16 px-4 border-b border-gray-200" :class="sidebarCollapsed ? 'justify-center' : 'justify-between'">
<div class="flex items-center space-x-2"> <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"> <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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg> </svg>
</div> </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> </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> </div>
<!-- Navigation --> <!-- 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 <router-link
to="/dashboard" to="/dashboard"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors group" class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/dashboard' }" :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"> <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" /> <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> </svg>
Dashboard <span v-if="!sidebarCollapsed" class="truncate">{{ t('app.dashboard') }}</span>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.role === 'admin'"
to="/users" to="/users"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors" class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/users' }" :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"> <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" /> <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> </svg>
Users <span v-if="!sidebarCollapsed" class="truncate">{{ t('app.users') }}</span>
</router-link> </router-link>
<router-link <router-link
to="/restaurants" to="/restaurants"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors" class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/restaurants' }" :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"> <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" /> <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> </svg>
Restaurants <span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
</router-link> </router-link>
<router-link <router-link
to="/settings" v-if="userStore.role === 'admin'"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors" to="/olap-columns"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/settings' }" 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('app.olapColumns') : ''"
> >
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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('app.olapColumns') }}</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') : ''"
>
<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') : ''"
>
<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="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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
Settings <span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
</router-link> </router-link>
</nav> </nav>
<!-- User Profile --> <!-- User Info (collapsed aware) -->
<div class="p-4 border-t border-gray-200"> <div v-if="!sidebarCollapsed" class="p-4 border-t border-gray-200">
<div class="flex items-center space-x-3"> <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"> <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 }} {{ userInitials }}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ userName }}</p> <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> </div>
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"> </div>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </div>
<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" /> <div v-else class="p-2 border-t border-gray-200 flex justify-center">
</svg> <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">
</button> {{ userInitials }}
</div> </div>
</div> </div>
</div> </div>
</aside> </aside>
<!-- Main Content --> <!-- Main Content -->
<main class="ml-64"> <main class="transition-all duration-300" :class="sidebarCollapsed ? 'ml-16' : 'ml-64'">
<!-- Header --> <!-- Header (без заголовка) -->
<header class="bg-white border-b border-gray-200"> <header class="bg-white border-b border-gray-200 sticky top-0 z-10">
<div class="flex items-center justify-between h-16 px-8"> <div class="flex items-center justify-end h-16 px-8">
<h1 class="text-2xl font-semibold text-gray-900">{{ pageTitle }}</h1>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<!-- Search --> <!-- Search -->
<div class="relative"> <div class="relative">
<input <input
type="text" 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" 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"> <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 +166,42 @@
</div> </div>
<!-- Notifications --> <!-- 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"> <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" /> <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> </svg>
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span> <span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button> </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>
</div> </div>
</header> </header>
@@ -118,37 +211,87 @@
<slot /> <slot />
</div> </div>
</main> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' 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'
const { notification, showNotification } = useNotification()
const settings = useSettingsStore()
const userStore = useUserStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userName = ref('Loading...') const { t, locale } = useI18n()
const userLogin = ref('')
onMounted(async () => { const userName = computed(() => userStore.login || 'User')
try { const userInitials = computed(() => (userName.value[0] || 'U').toUpperCase())
const res = await fetch('/api/admin/me')
if (res.ok) { const SIDEBAR_STORAGE_KEY = 'admin_sidebar_collapsed'
const data = await res.json() const sidebarCollapsed = ref(false)
userLogin.value = data.login
userName.value = data.login // или можно сделать красивое отображение onMounted(() => {
} const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY)
} catch (e) { if (saved !== null) sidebarCollapsed.value = saved === 'true'
userName.value = 'User'
}
}) })
const userInitials = computed(() => { function toggleSidebar() {
return (userName.value[0] || 'U').toUpperCase() sidebarCollapsed.value = !sidebarCollapsed.value
}) localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarCollapsed.value))
}
async function logout() { async function logout() {
await fetch('/api/logout', { method: 'POST' }) await fetch('/api/logout', { method: 'POST' })
userStore.clear()
router.push('/login') 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> </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,264 @@
{
"app": {
"title": "Admin Panel",
"dashboard": "Dashboard",
"database": "Data Base",
"users": "Users",
"restaurants": "Restaurants",
"settings": "Settings",
"olapColumns": "OLAP Fields",
"profile": "Profile",
"logout": "Logout",
"language": "Language",
"search": "Search...",
"notifications": "Notifications",
"administrator": "Administrator",
"user": "User",
"yes": "Yes",
"no": "No",
"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"
},
"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",
"language": "Language",
"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"
},
"olap": {
"columnsTitle": "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."
},
"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,264 @@
{
"app": {
"title": "Панель администратора",
"dashboard": "Панель управления",
"database": "База данных",
"users": "Пользователи",
"restaurants": "Рестораны",
"settings": "Настройки",
"olapColumns": "OLAP поля",
"profile": "Профиль",
"logout": "Выйти",
"language": "Язык",
"search": "Поиск...",
"notifications": "Уведомления",
"administrator": "Администратор",
"user": "Пользователь",
"yes": "Да",
"no": "Нет",
"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": "Ошибка сети"
},
"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": "Подтверждение нового пароля",
"language": "Язык",
"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": "Пароли не совпадают"
},
"olap": {
"columnsTitle": "Структура 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": "Вы уверены, что хотите удалить это поле? Это действие необратимо."
},
"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 { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' 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) const app = createApp(App)
app.use(createPinia()) const pinia = createPinia()
app.use(pinia)
app.use(router) app.use(router)
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') app.mount('#app')
})

View File

@@ -1,17 +1,34 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import Login from '../views/auth/Login.vue' import { useUserStore } from '@/stores/user'
import Setup from '../views/auth/Setup.vue' import { useSettingsStore } from '@/stores/settings'
import Register from '../views/auth/Register.vue' import Login from '@/views/auth/Login.vue'
import Dashboard from '../views/Dashboard.vue' import Setup from '@/views/auth/Setup.vue'
import Users from '../views/Users.vue' import Register from '@/views/auth/Register.vue'
import Restaurants from '../views/Restaurants.vue' import Dashboard from '@/views/Dashboard.vue'
import AdminSettings from '../views/AdminSettings.vue' import Users from '@/views/Users.vue'
import NotFound from '../views/NotFound.vue' import Restaurants from '@/views/Restaurants.vue'
import OlapColumnsView from '@/views/OlapColumnsView.vue'
import DBConnections from '@/views/DBConnections.vue'
import AdminSettings from '@/views/AdminSettings.vue'
import Profile from '@/views/Profile.vue'
import NotFound from '@/views/NotFound.vue'
const routes = [ const routes = [
{ path: '/login', component: Login, meta: { title: 'Login' } }, {
{ path: '/register', component: Register, meta: { title: 'Register' } }, path: '/login',
{ path: '/setup', component: Setup, meta: { title: 'Setup' } }, 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: '/', path: '/',
redirect: '/dashboard' redirect: '/dashboard'
@@ -24,17 +41,32 @@ const routes = [
{ {
path: '/users', path: '/users',
component: Users, component: Users,
meta: { requiresAuth: true, title: 'Users' } meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' }
}, },
{ {
path: '/restaurants', path: '/restaurants',
component: Restaurants, component: Restaurants,
meta: { requiresAuth: true, title: 'Restaurants' } meta: { requiresAuth: true, title: 'Restaurants' }
}, },
{
path: '/olap-columns',
component: OlapColumnsView,
meta: { requiresAuth: true, requiresAdmin: true, title: 'OlapColumns' }
},
{
path: '/database-connections',
component: DBConnections,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Database Connections' }
},
{ {
path: '/settings', path: '/settings',
component: AdminSettings, 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(.*)*', path: '/:pathMatch(.*)*',
@@ -44,54 +76,49 @@ const routes = [
} }
] ]
const router = createRouter({ const router = createRouter({ history: createWebHistory(), routes })
history: createWebHistory(),
routes
})
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
// Update page title const settings = useSettingsStore()
document.title = `${to.meta.title || 'Admin Panel'} | AdminPanel` 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 { try {
const statusRes = await fetch('/api/status') const statusRes = await fetch('/api/status')
const status = await statusRes.json() const status = await statusRes.json()
if (status.needsSetup && to.path !== '/setup') { if (status.needsSetup && to.path !== '/setup') {
next('/setup') next('/setup')
return return
} }
} catch (e) { } catch (e) { console.error('Failed to check status', e) }
console.error('Failed to check status', e)
const userStore = useUserStore()
// Если профиль ещё не загружен загружаем
if (userStore.role === '') {
await userStore.fetchProfile()
} }
if (to.path === '/login') { // Если уже залогинены и пытаемся зайти на login/register редирект на дашборд
try { if (userStore.id && (to.path === '/login' || to.path === '/register')) {
const meRes = await fetch('/api/admin/me'); next('/dashboard')
if (meRes.ok) { return
next('/dashboard'); }
return;
} // Проверка доступности регистрации
} catch (e) { if (to.path === '/register' && !settings.enableRegistration) {
// игнорируем ошибку, продолжаем next('/login')
} return
} }
// Check authentication
const requiresAuth = to.matched.some(record => record.meta.requiresAuth) const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
if (requiresAuth) { if (requiresAuth && !userStore.id) {
try {
const res = await fetch('/api/admin/me')
if (!res.ok) {
next('/login') next('/login')
} else { } else if (requiresAdmin && userStore.role !== 'admin') {
next() next('/dashboard')
}
} catch {
next('/login')
}
} else { } else {
next() 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

@@ -1,7 +1,7 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="card"> <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"> <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"> <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"> <label class="block text-sm font-medium text-gray-700 mb-1">
@@ -54,22 +54,22 @@
</div> </div>
<div class="flex justify-end space-x-3 pt-4"> <div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="loadData" class="btn-secondary">Reset</button> <button type="button" @click="loadData" class="btn-secondary">{{ t('settings.reset') }}</button>
<button type="submit" class="btn-primary">Save Changes</button> <button type="submit" class="btn-primary">{{ t('settings.save') }}</button>
</div> </div>
</form> </form>
<div v-if="message" class="mt-4 p-3 rounded-lg" :class="messageClass">
{{ message }}
</div>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; 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 { interface FieldMeta {
key: string; key: string;
label: string; label: string;
@@ -82,24 +82,22 @@ interface FieldMeta {
const meta = ref<FieldMeta[]>([]); const meta = ref<FieldMeta[]>([]);
const values = ref<Record<string, string>>({}); const values = ref<Record<string, string>>({});
const message = ref('');
const messageClass = ref('');
async function loadMeta() { async function loadMeta() {
const res = await fetch('/api/settings/meta'); const res = await fetch('/api/admin/settings/meta');
if (res.ok) { if (res.ok) {
meta.value = await res.json(); meta.value = await res.json();
} else { } else {
showMessage('Failed to load settings metadata', 'bg-red-50 text-red-800'); showNotification('settings.loadMetaError', 'error');
} }
} }
async function loadValues() { async function loadValues() {
const res = await fetch('/api/settings/all'); const res = await fetch('/api/admin/settings');
if (res.ok) { if (res.ok) {
values.value = await res.json(); values.value = await res.json();
} else { } 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), body: JSON.stringify(values.value),
}); });
if (res.ok) { if (res.ok) {
showMessage('Settings saved successfully', 'bg-green-50 text-green-800'); showNotification('settings.saveSuccess', 'success');
} else { } else {
showMessage('Failed to save settings', 'bg-red-50 text-red-800'); showNotification('settings.saveError', 'error');
} }
} catch (e) { } 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); onMounted(loadData);
</script> </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

@@ -2,68 +2,71 @@
<AppLayout> <AppLayout>
<!-- Stats Grid --> <!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <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 class="flex items-center justify-between">
<div> <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> <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalUsers }}</p>
</div> </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"> <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> </svg>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center text-sm"> <div class="mt-4 flex items-center text-sm">
<span class="text-green-600 font-medium"> 12%</span> <span class="text-green-600 font-medium"> {{ userGrowth }}%</span>
<span class="text-gray-500 ml-2">from last month</span> <span class="text-gray-500 ml-2">{{ t('dashboard.vsLastMonth') }}</span>
</div> </div>
</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 class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-gray-600">Active Sessions</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.activeSessions }}</p> <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalRestaurants }}</p>
</div> </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"> <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" /> <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> </svg>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center text-sm"> <div class="mt-4 flex items-center text-sm">
<span class="text-green-600 font-medium"> 5%</span> <span class="text-green-600 font-medium"> {{ sessionGrowth }}%</span>
<span class="text-gray-500 ml-2">from last hour</span> <span class="text-gray-500 ml-2">{{ t('dashboard.fromLastHour') }}</span>
</div> </div>
</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 class="flex items-center justify-between">
<div> <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> <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.systemHealth }}%</p>
</div> </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"> <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" /> <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> </svg>
</div> </div>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<div class="h-2 bg-gray-200 rounded-full"> <div class="h-2 bg-gray-200 rounded-full overflow-hidden">
<div class="h-2 bg-blue-600 rounded-full" :style="{ width: `${stats.systemHealth}%` }"></div> <div
class="h-full bg-blue-600 rounded-full transition-all duration-500"
:style="{ width: `${stats.systemHealth}%` }"
></div>
</div> </div>
</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 class="flex items-center justify-between">
<div> <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> <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.uptime }}</p>
</div> </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"> <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" /> <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> </svg>
@@ -72,16 +75,58 @@
<div class="mt-4 flex items-center text-sm"> <div class="mt-4 flex items-center text-sm">
<div class="flex items-center text-green-600"> <div class="flex items-center text-green-600">
<div class="w-2 h-2 bg-green-600 rounded-full mr-2"></div> <div class="w-2 h-2 bg-green-600 rounded-full mr-2"></div>
Operational {{ t('dashboard.operational') }}
</div> </div>
</div> </div>
</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="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card"> <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 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 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"> <div class="flex items-center space-x-3">
@@ -93,110 +138,112 @@
<p class="text-sm text-gray-500">{{ formatDate(user.created) }}</p> <p class="text-sm text-gray-500">{{ formatDate(user.created) }}</p>
</div> </div>
</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>
<div v-if="recentUsers.length === 0" class="text-center text-gray-500 py-8">{{ t('dashboard.noUsers') }}</div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h3 class="text-lg font-semibold text-gray-900 mb-4">System Status</h3> <div class="flex justify-between items-center mb-4">
<div class="space-y-4"> <h3 class="text-lg font-semibold text-gray-900">{{ t('dashboard.recentRestaurants') }}</h3>
<div v-for="service in systemServices" :key="service.name" class="flex items-center justify-between"> <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="flex items-center space-x-3">
<div :class="['w-2 h-2 rounded-full', service.status === 'up' ? 'bg-green-500' : 'bg-red-500']"></div> <div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<span class="text-gray-700">{{ service.name }}</span> <svg class="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div> </div>
<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> </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>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue' import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
import { useNotification } from '@/composables/useNotification'
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 { try {
const [usersRes, sessionsRes, healthRes] = await Promise.all([ const [usersRes, healthRes, restaurantsRes] = await Promise.all([
fetch('/api/admin/users'), fetch('/api/admin/users'),
fetch('/api/admin/active-sessions'), fetch('/api/health'),
fetch('/api/health') fetch('/api/admin/restaurants')
]) ]);
const users = await usersRes.json()
const sessions = await sessionsRes.json()
const health = await healthRes.json()
stats.value.totalUsers = users.length const users = await usersRes.json();
stats.value.activeSessions = sessions.count || 0 const health = await healthRes.json();
const restaurants = await restaurantsRes.json();
const upCount = health.checks?.filter(c => c.status === 'UP').length || 0 stats.value.totalUsers = users.length;
const total = health.checks?.length || 1 stats.value.totalRestaurants = restaurants.length;
stats.value.systemHealth = Math.round((upCount / total) * 100) recentUsers.value = users.slice(-5).reverse();
} catch (e) { console.error(e) } recentRestaurants.value = restaurants.slice(-5).reverse();
}
onMounted(() => { const upCount = health.checks?.filter((c: any) => c.status === 'UP').length || 0;
loadStats() const total = health.checks?.length || 1;
const interval = setInterval(loadStats, 5000) stats.value.systemHealth = Math.round((upCount / total) * 100);
onUnmounted(() => clearInterval(interval))
})
const recentUsers = ref([]) if (health.checks) {
const systemServices = ref([]) systemServices.value = health.checks.map((check: any) => ({
async function loadHealth() {
try {
const res = await fetch('/api/health')
const data = await res.json()
if (data.checks) {
systemServices.value = data.checks.map(check => ({
name: check.data?.name || check.id, name: check.data?.name || check.id,
status: check.status.toLowerCase(), status: check.status.toLowerCase(),
latency: check.data?.latency_ms || 0 latency: check.data?.latency_ms || 0
})) }));
} }
} catch (e) { } catch (e) {
console.error('Health check failed', e) showNotification('dashboard.loadError', 'error');
} console.error('Failed to load dashboard data', 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)
} }
} }
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-US', { if (!dateStr) return '-';
month: 'short', const date = new Date(dateStr);
day: 'numeric', const now = new Date();
hour: '2-digit', const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
minute: '2-digit' 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> </script>

View File

@@ -9,20 +9,20 @@
</div> </div>
</div> </div>
<h1 class="text-6xl font-bold text-gray-900 mb-4">404</h1> <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-xl text-gray-600 mb-8">{{ t('notFound.title') }}</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-gray-500 mb-8">{{ t('notFound.message') }}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center"> <div class="flex flex-col sm:flex-row gap-4 justify-center">
<router-link <router-link
to="/dashboard" to="/dashboard"
class="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors" 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>
<router-link <router-link
to="/login" to="/login"
class="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" 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> </router-link>
</div> </div>
</div> </div>
@@ -30,5 +30,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// No additional logic needed import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script> </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('olap.columnsTitle') }}</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('olap.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('olap.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('olap.filterFieldKey') }}</label>
<input v-model="filters.fieldKey" type="text" class="input-field" :placeholder="t('olap.filterFieldKeyPlaceholder')" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.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('olap.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('olap.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('olap.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('olap.reportTypes') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.type') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.tags') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.aggregation') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.grouping') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.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('olap.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('olap.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('olap.refreshWarningTitle') }}</h3>
<p class="text-sm text-gray-500 mb-4">
{{ t('olap.refreshWarningMessage', { restaurant: pendingRestaurantName }) }}
</p>
<p class="text-sm font-semibold text-red-600 mb-6">{{ t('olap.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('olap.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('olap.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('olap.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('olap.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('olap.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('olap.deleteField') }}</h3>
<p class="text-sm text-gray-500 mb-6">{{ t('olap.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('olap.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('olap.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('olap.selectRestaurant');
loadRestaurants().then(() => { initModalOpen.value = true; });
}
function openRefreshModal() {
initModalTitle.value = t('olap.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('olap.selectRestaurantFirst', 'error');
return;
}
initModalOpen.value = false;
refreshWarningModal.value.show = false;
editModalOpen.value = false;
deleteFieldConfirm.value.show = false;
initializingText.value = hasData.value ? t('olap.refreshingData') : t('olap.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('olap.initSuccess', 'success');
await loadColumns();
} catch (error: any) {
showNotification('olap.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('olap.updateSuccess', 'success');
closeEditModal();
await loadColumns();
} catch (error) {
showNotification('olap.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('olap.deleteSuccess', 'success');
deleteFieldConfirm.value.show = false;
await loadColumns();
} catch (error) {
showNotification('olap.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>

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> <template>
<AppLayout> <AppLayout>
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">Restaurants</h1> <h1 class="text-2xl font-bold text-gray-900">{{ t('restaurants.pageName') }}</h1>
<button @click="openModal('create')" class="btn-primary">+ Add Restaurant</button> <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>
<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"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <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 tracking-wider">{{ t('common.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 tracking-wider">{{ t('common.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 tracking-wider">{{ t('restaurants.host') }}</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 tracking-wider">{{ t('restaurants.https') }}</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 tracking-wider">{{ t('common.login') }}</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.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> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="rest in restaurants" :key="rest.id"> <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 text-gray-900">{{ rest.id }}</td> <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-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">{{ 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">
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2"> <label class="relative inline-flex items-center cursor-pointer">
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800">Edit</button> <input type="checkbox" :checked="rest.https" @change="toggleHttps(rest)" class="sr-only peer" />
<button @click="deleteRestaurant(rest.id)" class="text-red-600 hover:text-red-800">Delete</button> <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>
<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> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- Modal --> <!-- Модалка создания/редактирования (без изменений) -->
<div v-if="modalOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <Transition name="fade">
<div class="bg-white rounded-lg p-6 w-full max-w-md"> <div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2> <div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<form @submit.prevent="submitRestaurant"> <div class="flex items-center justify-center min-h-screen p-4">
<div class="mb-4"> <div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
<label class="block text-sm font-medium text-gray-700">Name</label> <div class="flex justify-between items-center p-6 border-b">
<input v-model="form.name" type="text" required class="input-field mt-1" /> <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>
<div class="mb-4"> <form @submit.prevent="submitRestaurant" class="p-6 space-y-5">
<label class="block text-sm font-medium text-gray-700">Login</label> <div>
<input v-model="form.login" type="text" required class="input-field mt-1" /> <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>
<div class="mb-4"> <div>
<label class="block text-sm font-medium text-gray-700">Password</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('restaurants.host') }} *</label>
<input v-model="form.password" :required="modalMode === 'create'" type="password" class="input-field mt-1" /> <input v-model="form.host" type="text" required class="input-field" placeholder="e.g., api.example.com" />
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
</div> </div>
<div class="mb-4"> <div class="flex items-center">
<label class="block text-sm font-medium text-gray-700">Host</label> <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" />
<input v-model="form.host" type="text" required class="input-field mt-1" /> <label class="text-sm font-medium text-gray-700">{{ t('restaurants.useHttps') }}</label>
</div> </div>
<div class="flex justify-end space-x-2"> <div>
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.login') }} *</label>
<button type="submit" class="btn-primary">Save</button> <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> </div>
</form> </form>
</div> </div>
</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> </AppLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; 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 modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create'); 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 modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null });
async function loadRestaurants() { async function loadRestaurants() {
try {
const res = await fetch('/api/admin/restaurants'); 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) { function formatDate(dateStr: string) {
@@ -85,14 +198,67 @@ function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString(); 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; modalMode.value = mode;
if (mode === 'create') { if (mode === 'create') {
form.value = { id: null, name: '', login: '', password: '', host: '' }; form.value = { id: null, name: '', login: '', password: '', host: '', https: false };
modalTitle.value = 'Create Restaurant'; modalTitle.value = t('restaurants.add');
} else { } else if (rest) {
form.value = { id: rest.id, name: rest.name, login: rest.login, password: '', host: rest.host }; form.value = {
modalTitle.value = 'Edit Restaurant'; 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; modalOpen.value = true;
} }
@@ -102,39 +268,76 @@ function closeModal() {
} }
async function submitRestaurant() { async function submitRestaurant() {
if (modalMode.value === 'create' && !form.value.password) {
showNotification('restaurants.passwordRequired', 'error');
return;
}
try { try {
const payload = { const payload = {
name: form.value.name, name: form.value.name,
login: form.value.login,
host: form.value.host, host: form.value.host,
https: form.value.https,
login: form.value.login,
...(form.value.password ? { password: form.value.password } : {}) ...(form.value.password ? { password: form.value.password } : {})
}; };
if (modalMode.value === 'create') { if (modalMode.value === 'create') {
await fetch('/api/admin/restaurants', { const res = await fetch('/api/admin/restaurants', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (!res.ok) throw new Error();
showNotification('restaurants.createSuccess', 'success');
} else { } else {
await fetch(`/api/admin/restaurants/${form.value.id}`, { const res = await fetch(`/api/admin/restaurants/${form.value.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (!res.ok) throw new Error();
showNotification('restaurants.updateSuccess', 'success');
} }
await loadRestaurants(); await loadRestaurants();
closeModal(); closeModal();
} catch (e) { } 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) { async function deleteRestaurant(id: number) {
if (confirm('Are you sure?')) { try {
await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification('restaurants.deleteSuccess', 'success');
await loadRestaurants(); await loadRestaurants();
} catch (e) {
showNotification('restaurants.deleteError', 'error');
} finally {
deleteConfirm.value.show = false;
} }
} }
onMounted(loadRestaurants); onMounted(loadRestaurants);
</script> </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> <template>
<AppLayout> <AppLayout>
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">Users Management</h1> <h1 class="text-2xl font-bold text-gray-900">{{ t('users.pageName') }}</h1>
<button @click="openModal('create')" class="btn-primary">+ Add User</button> <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>
<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"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <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 tracking-wider">{{ t('common.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 tracking-wider">{{ t('common.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 tracking-wider">{{ t('common.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 tracking-wider">{{ t('common.role') }}</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 tracking-wider">{{ t('common.status') }}</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 tracking-wider">{{ t('common.ip') }}</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.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> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="user in users" :key="user.id"> <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">{{ user.id }}</td> <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">{{ user.login }}</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">{{ user.email }}</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"> <td class="px-6 py-4 whitespace-nowrap text-sm">
<button <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'">
v-if="user.id !== currentUserId" {{ user.role === 'admin' ? t('app.administrator') : t('app.user') }}
@click="toggleActive(user)" </span>
: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>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.ip || '-' }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm">
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ formatDate(user.created) }}</td> <div v-if="user.id === userStore.id" class="text-xs text-gray-500">{{ t('users.you') }}</div>
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2"> <label v-else class="relative inline-flex items-center cursor-pointer">
<button @click="openModal('edit', user)" class="text-blue-600">Edit</button> <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 <button
v-if="user.id !== currentUserId" v-if="user.id !== userStore.id"
@click="deleteUser(user.id)" @click="confirmDelete(user.id)"
class="text-red-600" 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> </button>
</td> </td>
</tr> </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> </tbody>
</table> </table>
</div> </div>
</div>
<!-- Modal --> <!-- Modal for create/edit user -->
<div v-if="modalOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <Transition name="fade">
<div class="bg-white rounded-lg p-6 w-full max-w-md"> <div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2> <div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<form @submit.prevent="submitUser"> <div class="flex items-center justify-center min-h-screen p-4">
<div class="mb-4"> <div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
<label class="block text-sm font-medium text-gray-700">Email</label> <div class="flex justify-between items-center p-6 border-b">
<input v-model="form.email" type="text" required class="input-field mt-1" /> <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>
<div class="mb-4"> <form @submit.prevent="submitUser" class="p-6 space-y-5">
<label class="block text-sm font-medium text-gray-700">Login</label> <div>
<input v-model="form.login" type="text" required class="input-field mt-1" /> <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>
<div class="mb-4"> <div>
<label class="block text-sm font-medium text-gray-700">Password</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.login') }} *</label>
<input v-model="form.password" :required="modalMode === 'create'" type="password" class="input-field mt-1" /> <input v-model="form.login" type="text" required class="input-field" />
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
</div> </div>
<div class="flex justify-end space-x-2"> <div>
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.role') }}</label>
<button type="submit" class="btn-primary">Save</button> <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> </div>
</form> </form>
</div> </div>
</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> </AppLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, computed } from 'vue';
import AppLayout from '../components/Layout/AppLayout.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() { const users = ref<any[]>([]);
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 modalOpen = ref(false); const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create'); 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 modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null });
const isEditingSelf = computed(() => {
return modalMode.value === 'edit' && form.value.id === userStore.id;
});
async function loadUsers() { async function loadUsers() {
try {
const res = await fetch('/api/admin/users'); const res = await fetch('/api/admin/users');
if (!res.ok) throw new Error();
users.value = await res.json(); users.value = await res.json();
} catch (e) {
showNotification('users.loadError', 'error');
}
} }
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
@@ -113,18 +191,24 @@ function formatDate(dateStr: string) {
} }
async function toggleActive(user: any) { async function toggleActive(user: any) {
await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' }) try {
await loadUsers() 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) { function openModal(mode: 'create' | 'edit', user: any = null) {
modalMode.value = mode; modalMode.value = mode;
if (mode === 'create') { if (mode === 'create') {
form.value = { id: null, login: '', email: '', password: '' }; form.value = { id: null, login: '', email: '', password: '', role: 'user' };
modalTitle.value = 'Create User'; modalTitle.value = t('users.add');
} else { } else {
form.value = { id: user.id, login: user.login, email: user.email, password: '' }; // добавлен email form.value = { id: user.id, login: user.login, email: user.email, password: '', role: user.role || 'user' };
modalTitle.value = 'Edit User'; modalTitle.value = t('users.edit');
} }
modalOpen.value = true; modalOpen.value = true;
} }
@@ -134,49 +218,81 @@ function closeModal() {
} }
async function submitUser() { 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 { try {
const payload: any = { const payload: any = {
login: form.value.login, login: form.value.login,
email: form.value.email, email: form.value.email,
role: form.value.role,
}; };
if (form.value.password) { if (form.value.password) {
payload.password = form.value.password; payload.password = form.value.password;
} }
let response;
if (modalMode.value === 'create') { if (modalMode.value === 'create') {
if (!form.value.password) { response = await fetch('/api/admin/users', {
alert('Password is required');
return;
}
const res = await fetch('/api/admin/users', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error('Create failed'); if (!response.ok) throw new Error();
showNotification('users.createSuccess', 'success');
} else { } else {
const res = await fetch(`/api/admin/users/${form.value.id}`, { response = await fetch(`/api/admin/users/${form.value.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error('Update failed'); if (!response.ok) throw new Error();
showNotification('users.updateSuccess', 'success');
} }
await loadUsers(); await loadUsers();
closeModal(); closeModal();
} catch (e) { } 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) { async function deleteUser(id: number) {
if (confirm('Are you sure?')) { try {
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification('users.deleteSuccess', 'success');
await loadUsers(); await loadUsers();
} catch (e) {
showNotification('users.deleteError', 'error');
} finally {
deleteConfirm.value.show = false;
} }
} }
onMounted(async () => { onMounted(async () => {
await loadCurrentUser();
await loadUsers(); await loadUsers();
}); });
</script> </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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg> </svg>
</div> </div>
<h1 class="text-3xl font-bold text-gray-900">Welcome Back</h1> <h1 class="text-3xl font-bold text-gray-900">{{ t('login.title') }}</h1>
<p class="text-gray-600 mt-2">Sign in to your account</p> <p class="text-gray-600 mt-2">{{ t('login.subtitle') }}</p>
</div> </div>
<!-- Login Form --> <!-- Login Form -->
<div class="bg-white rounded-2xl shadow-xl p-8"> <div class="bg-white rounded-2xl shadow-xl p-8">
<form @submit.prevent="handleLogin" class="space-y-6"> <form @submit.prevent="handleLogin" class="space-y-6">
<div> <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="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -34,7 +34,7 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label> <label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.password') }}</label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <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"> <div class="flex items-center justify-between">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" /> <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> </label>
<router-link to="/register" class="text-sm text-primary-600 hover:text-primary-700"> <router-link
Create account v-if="settings.enableRegistration"
to="/register"
class="text-sm text-primary-600 hover:text-primary-700"
>
{{ t('login.createAccount') }}
</router-link> </router-link>
</div> </div>
@@ -77,7 +81,7 @@
:disabled="loading" :disabled="loading"
class="w-full btn-primary py-3 relative" 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"> <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> <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> <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>
@@ -99,8 +103,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useSettingsStore } from '@/stores/settings'
import { useUserStore } from '@/stores/user'
import { useI18n } from 'vue-i18n'
const settings = useSettingsStore()
const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const { t, locale } = useI18n()
const form = ref({ login: '', password: '' }) const form = ref({ login: '', password: '' })
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
@@ -109,27 +119,26 @@ const showPassword = ref(false)
async function handleLogin() { async function handleLogin() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const res = await fetch('/api/login', { const res = await fetch('/api/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value) body: JSON.stringify(form.value)
}) })
if (res.ok) { if (res.ok) {
await userStore.fetchProfile()
// Устанавливаем язык интерфейса из профиля
if (userStore.language) {
locale.value = userStore.language
localStorage.setItem('locale', userStore.language)
}
router.push('/dashboard') router.push('/dashboard')
} else { } else {
// Пытаемся получить текст ошибки от сервера
const text = await res.text() const text = await res.text()
if (text && text.trim()) { error.value = text || t('login.invalidCredentials')
error.value = text
} else {
error.value = 'Invalid username or password'
}
} }
} catch (e) { } catch (e) {
error.value = 'Network error. Please try again.' error.value = t('login.networkError')
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -7,32 +7,32 @@
<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" /> <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> </svg>
</div> </div>
<h1 class="text-3xl font-bold text-gray-900">Create Account</h1> <h1 class="text-3xl font-bold text-gray-900">{{ t('register.title') }}</h1>
<p class="text-gray-600 mt-2">Register and wait for admin approval</p> <p class="text-gray-600 mt-2">{{ t('register.subtitle') }}</p>
</div> </div>
<div class="bg-white rounded-2xl shadow-xl p-8"> <div class="bg-white rounded-2xl shadow-xl p-8">
<form @submit.prevent="handleRegister" class="space-y-6"> <form @submit.prevent="handleRegister" class="space-y-6">
<div> <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" /> <input v-model="form.login" type="text" required minlength="3" class="input-field" />
</div> </div>
<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" /> <input v-model="form.email" type="email" required class="input-field" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label> <label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.password') }}</label>
<input v-model="form.password" type="password" required minlength="6" class="input-field" /> <input v-model="form.password" type="password" required minlength="6" class="input-field" />
</div> </div>
<button type="submit" :disabled="loading" class="w-full btn-primary py-3"> <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> <span v-else>Loading...</span>
</button> </button>
</form> </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 v-if="error" class="mt-4 text-red-600 text-center">{{ error }}</p>
<p class="mt-4 text-center text-sm text-gray-600"> <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> </p>
</div> </div>
</div> </div>
@@ -41,6 +41,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const form = ref({ login: '', email: '', password: '' }) const form = ref({ login: '', email: '', password: '' })
const loading = ref(false) const loading = ref(false)
const error = ref('') 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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg> </svg>
</div> </div>
<h1 class="text-3xl font-bold text-gray-900">Setup Admin Account</h1> <h1 class="text-3xl font-bold text-gray-900">{{ t('setup.title') }}</h1>
<p class="text-gray-600 mt-2">Create your administrator account</p> <p class="text-gray-600 mt-2">{{ t('setup.subtitle') }}</p>
</div> </div>
<!-- Setup Form --> <!-- Setup Form -->
@@ -19,19 +19,19 @@
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<div class="flex items-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="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>
<div class="mx-4 w-12 h-0.5 bg-gray-300"></div> <div class="mx-4 w-12 h-0.5 bg-gray-300"></div>
<div class="flex items-center"> <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="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> </div>
</div> </div>
<form @submit.prevent="handleSetup" class="space-y-6"> <form @submit.prevent="handleSetup" class="space-y-6">
<div> <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="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -52,7 +52,7 @@
</div> </div>
<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="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -72,7 +72,7 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label> <label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.password') }}</label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -105,7 +105,7 @@
<!-- Password Strength --> <!-- Password Strength -->
<div v-if="form.password" class="mt-2"> <div v-if="form.password" class="mt-2">
<div class="flex items-center justify-between mb-1"> <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> <span class="text-xs font-medium" :class="strengthColor">{{ strengthText }}</span>
</div> </div>
<div class="h-1.5 bg-gray-200 rounded-full overflow-hidden"> <div class="h-1.5 bg-gray-200 rounded-full overflow-hidden">
@@ -123,7 +123,7 @@
:disabled="loading" :disabled="loading"
class="w-full btn-primary py-3 relative overflow-hidden group" 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"> <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> <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> <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"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter() const router = useRouter()
const form = ref({ login: '', email: '', password: '' }); const form = ref({ login: '', email: '', password: '' });
const loading = ref(false) const loading = ref(false)
@@ -186,8 +187,9 @@ const passwordStrength = computed(() => {
const strengthPercent = computed(() => (passwordStrength.value / 5) * 100) const strengthPercent = computed(() => (passwordStrength.value / 5) * 100)
const strengthText = computed(() => { const strengthText = computed(() => {
const texts = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'] const keys = ['setup.veryWeak', 'setup.weak', 'setup.fair', 'setup.good', 'setup.strong']
return texts[passwordStrength.value - 1] || '' const key = keys[passwordStrength.value - 1]
return key ? t(key) : ''
}) })
const strengthColor = computed(() => { 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({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@': '/src',
},
},
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:8080' // для разработки '/api': 'http://localhost:8080' // для разработки

View File

@@ -0,0 +1,91 @@
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;
}
location /phpmyadmin/ {
allow 80.68.9.83;
allow 185.51.125.202;
# Локальные сети
allow 192.168.0.0/16; # 192.168.0.0 - 192.168.255.255
allow 10.0.0.0/8; # 10.0.0.0 - 10.255.255.255
allow 172.16.0.0/12; # 172.16.0.0 - 172.31.255.255
allow fd00::/8; # IPv6 ULA (аналог приватных IPv4)
allow fe80::/10; # IPv6 link-local
# Localhost
allow 127.0.0.0/8; # 127.0.0.0 - 127.255.255.255
allow ::1; # IPv6 localhost
# Docker сети (если используете)
allow 172.17.0.0/16;
allow 172.18.0.0/16;
deny all;
proxy_pass http://127.0.0.1:7102/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/iiko-app.dev.xserver.su/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/iiko-app.dev.xserver.su/privkey.pem;
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

@@ -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,22 +6,29 @@ import io.vertx.config.ConfigStoreOptions;
import io.vertx.core.AbstractVerticle; import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future; import io.vertx.core.Future;
import io.vertx.core.Promise; import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpServer; import io.vertx.core.http.HttpServer;
import io.vertx.core.json.JsonObject; import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router; 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.BodyHandler;
import io.vertx.ext.web.handler.SessionHandler; import io.vertx.ext.web.handler.SessionHandler;
import io.vertx.ext.web.handler.StaticHandler; 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.SessionStore;
import io.vertx.ext.web.sstore.redis.RedisSessionStore;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import su.xserver.iikocon.config.AppConfig; import su.xserver.iikocon.config.AppConfig;
import su.xserver.iikocon.service.DataBaseService; import su.xserver.iikocon.handler.*;
import su.xserver.iikocon.service.HealthCheckService; import su.xserver.iikocon.iiko.IikoHandler;
import su.xserver.iikocon.service.RedisService; import su.xserver.iikocon.iiko.IikoOlapClient;
import su.xserver.iikocon.service.*;
import java.net.URI;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -33,13 +40,18 @@ public class MainVerticle extends AbstractVerticle {
private RedisService redis; private RedisService redis;
private HttpServer httpServer; private HttpServer httpServer;
private AppConfig config; private AppConfig config;
private SessionStore sessionStore;
private UserService userService; private UserService userService;
private RestaurantService restaurantService; private RestaurantService restaurantService;
private ExternalDataBaseService externalDataBaseService;
private SettingsService settingsService; private SettingsService settingsService;
@Override @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");
ConfigStoreOptions classpathStore = new ConfigStoreOptions() ConfigStoreOptions classpathStore = new ConfigStoreOptions()
.setType("file") .setType("file")
@@ -59,12 +71,11 @@ public class MainVerticle extends AbstractVerticle {
db = new DataBaseService(vertx, config.database); db = new DataBaseService(vertx, config.database);
redis = new RedisService(vertx, config.redis); redis = new RedisService(vertx, config.redis);
// Инициализация сервисов
userService = new UserService(db.getPool()); userService = new UserService(db.getPool());
restaurantService = new RestaurantService(db.getPool()); restaurantService = new RestaurantService(db.getPool());
settingsService = new SettingsService(db.getPool()); settingsService = new SettingsService(db.getPool());
externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx);
// Инициализация БД (создание таблицы users)
userService.initDatabase().onFailure(err -> { userService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err); log.error("Failed to initialize database", err);
startPromise.fail(err); startPromise.fail(err);
@@ -77,31 +88,154 @@ public class MainVerticle extends AbstractVerticle {
log.error("Failed to initialize database", err); log.error("Failed to initialize database", err);
startPromise.fail(err); startPromise.fail(err);
}); });
externalDataBaseService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
Router router = initRouter(); createRouterAndStartHttp(startPromise);
startHttp(router, startPromise);
}) })
.onFailure(startPromise::fail); .onFailure(startPromise::fail);
} }
private Router initRouter() { private void createRouterAndStartHttp(Promise<Void> startPromise) {
settingsService.get("session_timeout_minutes")
.compose(timeoutStr -> {
long timeoutMinutes = 60; // default
if (timeoutStr != null && !timeoutStr.isEmpty()) {
try {
timeoutMinutes = Long.parseLong(timeoutStr);
} catch (NumberFormatException ignored) {}
}
long timeoutMs = timeoutMinutes * 60 * 1000;
// Настройка сессий (используем LocalSessionStore для простоты) sessionStore = RedisSessionStore.create(vertx, redis.getRedis());
SessionStore sessionStore = LocalSessionStore.create(vertx);
SessionHandler sessionHandler = SessionHandler.create(sessionStore) SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionCookieName("admin.session") .setSessionCookieName("admin.session")
.setCookieHttpOnlyFlag(true) .setCookieHttpOnlyFlag(true)
.setCookieSecureFlag(false) .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 router = Router.router(vertx);
router.route().handler(BodyHandler.create());
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();
});
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); 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 для разработки // CORS для разработки
router.route().handler(ctx -> { router.route().handler(ctx -> {
ctx.response() ctx.response()
@@ -133,6 +267,13 @@ public class MainVerticle extends AbstractVerticle {
} }
}); });
// Rate Limiter Handler
RedisRateLimiter limiter = new RedisRateLimiter(
redis.getRedis(), 60, 60_000
);
router.route().handler(limiter);
// Health Checks // Health Checks
HealthCheckService healthCheckService = new HealthCheckService(vertx, redis, db); HealthCheckService healthCheckService = new HealthCheckService(vertx, redis, db);
healthCheckService.registerHealthCheck(router); healthCheckService.registerHealthCheck(router);
@@ -149,7 +290,11 @@ public class MainVerticle extends AbstractVerticle {
router.post("/api/logout").handler(authHandler::handleLogout); 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(); JsonObject body = rc.body().asJsonObject();
String login = body.getString("login"); String login = body.getString("login");
String email = body.getString("email"); String email = body.getString("email");
@@ -162,10 +307,30 @@ public class MainVerticle extends AbstractVerticle {
userService.createUser(login, email, password, ip) userService.createUser(login, email, password, ip)
.onSuccess(v -> rc.response().setStatusCode(201).end(new JsonObject().put("success", true).encode())) .onSuccess(v -> rc.response().setStatusCode(201).end(new JsonObject().put("success", true).encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .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.route("/api/admin/*").handler(authHandler::requireAuth);
router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> { router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> {
if (ar.succeeded()) { if (ar.succeeded()) {
rc.response() rc.response()
@@ -176,18 +341,20 @@ public class MainVerticle extends AbstractVerticle {
} }
})); }));
router.route("/api/admin/users*").handler(AdminHandler::requireAdmin);
router.post("/api/admin/users").handler(rc -> { router.post("/api/admin/users").handler(rc -> {
JsonObject body = rc.body().asJsonObject(); JsonObject body = rc.body().asJsonObject();
String login = body.getString("login"); String login = body.getString("login");
String email = body.getString("email"); String email = body.getString("email");
String password = body.getString("password"); String password = body.getString("password");
String role = body.getString("role");
String ip = rc.request().remoteAddress().host(); String ip = rc.request().remoteAddress().host();
if (login == null || email == null || password == null) { if (login == null || email == null || password == null) {
rc.response().setStatusCode(400).end("Missing login, email or password"); rc.response().setStatusCode(400).end("Missing login, email or password");
return; return;
} }
// Создаём активного пользователя (active = true) if (role == null || role.isEmpty()) role = "user";
userService.createUser(login, email, password, ip, true) userService.createUser(login, email, password, ip, true, role)
.onSuccess(v -> rc.response().setStatusCode(201).end()) .onSuccess(v -> rc.response().setStatusCode(201).end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}); });
@@ -198,12 +365,13 @@ public class MainVerticle extends AbstractVerticle {
String login = body.getString("login"); String login = body.getString("login");
String email = body.getString("email"); String email = body.getString("email");
String password = body.getString("password"); String password = body.getString("password");
String role = body.getString("role");
String ip = rc.request().remoteAddress().host(); String ip = rc.request().remoteAddress().host();
if (login == null || email == null) { if (login == null || email == null) {
rc.response().setStatusCode(400).end("Missing login or email"); rc.response().setStatusCode(400).end("Missing login or email");
return; return;
} }
userService.updateUser(id, login, email, password, ip) userService.updateUser(id, login, email, password, ip, role)
.onSuccess(v -> rc.response().end()) .onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}); });
@@ -226,7 +394,7 @@ public class MainVerticle extends AbstractVerticle {
router.put("/api/admin/users/:id/activate").handler(rc -> { router.put("/api/admin/users/:id/activate").handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id")); 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"); Integer currentUserId = rc.session().get("userId");
if (currentUserId != null && currentUserId == id) { if (currentUserId != null && currentUserId == id) {
@@ -239,21 +407,6 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .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 -> { router.get("/api/admin/restaurants").handler(rc -> restaurantService.getAllRestaurants().onComplete(ar -> {
if (ar.succeeded()) { if (ar.succeeded()) {
rc.response() rc.response()
@@ -274,17 +427,36 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .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 -> { router.post("/api/admin/restaurants").handler(rc -> {
JsonObject body = rc.body().asJsonObject(); JsonObject body = rc.body().asJsonObject();
String name = body.getString("name"); String name = body.getString("name");
String login = body.getString("login"); String login = body.getString("login");
String password = body.getString("password"); String password = body.getString("password");
String host = body.getString("host"); String host = body.getString("host");
boolean https = body.getBoolean("https", false);
if (name == null || login == null || password == null || host == null) { if (name == null || login == null || password == null || host == null) {
rc.response().setStatusCode(400).end("Missing fields"); rc.response().setStatusCode(400).end("Missing fields");
return; return;
} }
restaurantService.createRestaurant(name, login, password, host) restaurantService.createRestaurant(name, login, password, host, https)
.onSuccess(v -> rc.response().setStatusCode(201).end()) .onSuccess(v -> rc.response().setStatusCode(201).end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}); });
@@ -296,11 +468,12 @@ public class MainVerticle extends AbstractVerticle {
String login = body.getString("login"); String login = body.getString("login");
String password = body.getString("password"); String password = body.getString("password");
String host = body.getString("host"); String host = body.getString("host");
boolean https = body.getBoolean("https", false);
if (name == null || login == null || host == null) { if (name == null || login == null || host == null) {
rc.response().setStatusCode(400).end("Missing required fields"); rc.response().setStatusCode(400).end("Missing required fields");
return; return;
} }
restaurantService.updateRestaurant(id, name, login, password, host) restaurantService.updateRestaurant(id, name, login, password, host, https)
.onSuccess(v -> rc.response().end()) .onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}); });
@@ -312,28 +485,25 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}); });
// Получение всех настроек
router.get("/api/settings").handler(rc -> { router.get("/api/settings").handler(rc -> {
settingsService.getAll() settingsService.getPublicSettings()
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode())) .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.route("/api/admin/settings*").handler(AdminHandler::requireAdmin);
router.get("/api/settings/meta").handler(rc -> { router.get("/api/admin/settings/meta").handler(rc -> {
settingsService.getMetadata() settingsService.getMetadata()
.onSuccess(meta -> rc.response().putHeader("Content-Type", "application/json").end(meta.encode())) .onSuccess(meta -> rc.response().putHeader("Content-Type", "application/json").end(meta.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}); });
// Получить все настройки со значениями по умолчанию router.get("/api/admin/settings").handler(rc -> {
router.get("/api/settings/all").handler(rc -> {
settingsService.getAllWithDefaults() settingsService.getAllWithDefaults()
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode())) .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(err.getMessage()));
}); });
// Обновление настроек (админ)
router.put("/api/admin/settings").handler(rc -> { router.put("/api/admin/settings").handler(rc -> {
JsonObject body = rc.body().asJsonObject(); JsonObject body = rc.body().asJsonObject();
List<Future<Void>> futures = new ArrayList<>(); // явно указываем тип Future<Void> List<Future<Void>> futures = new ArrayList<>(); // явно указываем тип Future<Void>
@@ -343,17 +513,14 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}); });
// Количество активных сессий (на основе Redis) externalDataBaseService.handleRoute(router);
router.get("/api/admin/active-sessions").handler(rc -> {
// TODO: реализовать подсчёт активных сессий через Redis или другой механизм new IikoHandler(vertx, router, db, restaurantService, authHandler);
rc.response().end(new JsonObject().put("count", 0).encode());
});
return router; return router;
} }
private void startHttp(Router router, Promise<Void> startPromise) { private void startHttp(Router router, Promise<Void> startPromise) {
// Запуск HTTP-сервера
httpServer = vertx.createHttpServer(); httpServer = vertx.createHttpServer();
httpServer.requestHandler(router).listen(config.server.port, config.server.host) httpServer.requestHandler(router).listen(config.server.port, config.server.host)
.onSuccess(server -> { .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

@@ -8,6 +8,7 @@ public class AppConfig {
public ServerConfig server; public ServerConfig server;
public DatabaseConfig database; public DatabaseConfig database;
public RedisConfig redis; public RedisConfig redis;
public PhpMyAdminConfig pma;
public static AppConfig from(JsonObject json) { public static AppConfig from(JsonObject json) {
JsonObject resolved = json.copy(); JsonObject resolved = json.copy();
@@ -94,7 +95,8 @@ public class AppConfig {
return new JsonObject() return new JsonObject()
.put("server", server.json().getJsonObject("server")) .put("server", server.json().getJsonObject("server"))
.put("database", database.json().getJsonObject("database")) .put("database", database.json().getJsonObject("database"))
.put("redis", redis.json().getJsonObject("redis")); .put("redis", redis.json().getJsonObject("redis"))
.put("pma", pma.json().getJsonObject("pma"));
} }
@Override @Override

View File

@@ -0,0 +1,18 @@
package su.xserver.iikocon.config;
import io.vertx.core.json.JsonObject;
public class PhpMyAdminConfig {
public boolean enabled;
public String upstream;
public String basePath;
public JsonObject json() {
return new JsonObject()
.put("pma", new JsonObject()
.put("enabled", enabled)
.put("upstream", upstream)
.put("basePath", basePath)
);
}
}

View File

@@ -0,0 +1,15 @@
package su.xserver.iikocon.handler;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
public class AdminHandler {
public static void requireAdmin(RoutingContext ctx) {
String role = ctx.session().get("role");
if (!"admin".equals(role)) {
ctx.response().setStatusCode(403).end(new JsonObject().put("error", "Admin access required").encode());
return;
}
ctx.next();
}
}

View File

@@ -1,8 +1,9 @@
package su.xserver.iikocon; package su.xserver.iikocon.handler;
import io.vertx.core.json.JsonObject; import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.Session; import io.vertx.ext.web.Session;
import su.xserver.iikocon.service.UserService;
public class AuthHandler { public class AuthHandler {
private final UserService userService; private final UserService userService;
@@ -28,10 +29,8 @@ public class AuthHandler {
boolean passwordOk = userService.checkPassword(password, user.getString("password")); boolean passwordOk = userService.checkPassword(password, user.getString("password"));
if (passwordOk) { if (passwordOk) {
// Надёжное получение флага активности
Boolean active = user.getBoolean("active"); Boolean active = user.getBoolean("active");
if (active == null) { if (active == null) {
// Если поле отсутствует, пробуем получить как Integer (на случай TINYINT)
Integer activeInt = user.getInteger("active"); Integer activeInt = user.getInteger("active");
active = activeInt != null && activeInt == 1; active = activeInt != null && activeInt == 1;
} }
@@ -41,9 +40,21 @@ public class AuthHandler {
return; return;
} }
// Получаем реальный IP клиента (с учётом прокси, если настроен)
String clientIp = ctx.get("realClientIp");
if (clientIp == null) {
clientIp = ctx.request().remoteAddress().host();
}
// Обновляем IP в БД (асинхронно, не дожидаемся ответа)
userService.updateUserIp(user.getInteger("id"), clientIp)
.onFailure(err -> System.err.println("Failed to update IP for user " + user.getInteger("id") + ": " + err.getMessage()));
Session session = ctx.session(); Session session = ctx.session();
session.put("userId", user.getInteger("id")); session.put("userId", user.getInteger("id"));
session.put("login", user.getString("login")); session.put("login", user.getString("login"));
session.put("role", user.getString("role"));
session.put("language", user.getString("language"));
ctx.response().end(new JsonObject().put("success", true).put("login", user.getString("login")).encode()); ctx.response().end(new JsonObject().put("success", true).put("login", user.getString("login")).encode());
} else { } else {
ctx.response().setStatusCode(401).end("Invalid credentials"); ctx.response().setStatusCode(401).end("Invalid credentials");

View File

@@ -0,0 +1,185 @@
package su.xserver.iikocon.handler;
import io.vertx.core.Handler;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import io.vertx.redis.client.Command;
import io.vertx.redis.client.Redis;
import io.vertx.redis.client.Request;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class RedisRateLimiter implements Handler<RoutingContext> {
private final Logger logger;
private final Redis redis;
private final int limitPerWindow;
private final long windowMillis;
private static final String PREFIX = "ip:limit:";
// Основной кэш: clientKey -> время окончания блокировки
private final ConcurrentHashMap<String, Long> blockedClients = new ConcurrentHashMap<>();
// Индекс по времени: время окончания -> множество клиентов
private final ConcurrentSkipListMap<Long, Set<String>> expiryIndex = new ConcurrentSkipListMap<>();
private final ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor();
private final AtomicLong allowedRequests = new AtomicLong(0);
private final AtomicLong blockedRequests = new AtomicLong(0);
private final AtomicLong redisCalls = new AtomicLong(0);
private final AtomicLong redisFailures = new AtomicLong(0);
private final AtomicLong totalRedisLatency = new AtomicLong(0);
private final AtomicLong redisLatencyCount = new AtomicLong(0);
// Частота блокировок по IP
private final ConcurrentHashMap<String, AtomicLong> blockedByClient = new ConcurrentHashMap<>();
public RedisRateLimiter(Redis redis, int limitPerWindow, long windowMillis) {
this.logger = LoggerFactory.getLogger("[RedisRateLimiter]");
this.redis = redis;
this.limitPerWindow = limitPerWindow;
this.windowMillis = windowMillis;
// Периодическая очистка только истёкших блокировок
cleaner.scheduleAtFixedRate(this::cleanupExpiredClients, windowMillis, windowMillis / 2, TimeUnit.MILLISECONDS);
}
@Override
public void handle(RoutingContext context) {
String clientKey = getClientKey(context);
long now = System.currentTimeMillis();
// Проверяем локальную блокировку
Long blockedUntil = blockedClients.get(clientKey);
if (blockedUntil != null) {
if (blockedUntil > now) {
blockedRequests.incrementAndGet();
incrementBlockCount(clientKey);
sendTooManyRequests(context);
return;
} else {
unblockClient(clientKey, blockedUntil);
}
}
String redisKey = PREFIX + clientKey;
checkRateLimit(context, redisKey, clientKey);
}
private void checkRateLimit(RoutingContext context, String redisKey, String clientKey) {
String luaScript = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('PEXPIRE', key, ttl)
end
if current > limit then
return 'TOO_MANY_REQUESTS'
else
return 'OK'
end
""";
redisCalls.incrementAndGet();
long start = System.nanoTime();
Request request = Request.cmd(Command.EVAL)
.arg(luaScript)
.arg(1)
.arg(redisKey)
.arg(limitPerWindow)
.arg(windowMillis);
redis.send(request)
.onSuccess(response -> {
long duration = System.nanoTime() - start;
redisLatencyCount.incrementAndGet();
totalRedisLatency.addAndGet(TimeUnit.NANOSECONDS.toMillis(duration));
String result = response.toString();
if ("TOO_MANY_REQUESTS".equals(result)) {
blockClient(clientKey);
blockedRequests.incrementAndGet();
incrementBlockCount(clientKey);
sendTooManyRequests(context);
} else {
allowedRequests.incrementAndGet();
context.next();
}
}).onFailure(error -> {
redisFailures.incrementAndGet();
context.response()
.setStatusCode(503)
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("error", "503 Service Unavailable")
.put("message", "Redis is not connected")
.encodePrettily()
);
logger.error(error.getMessage());
});
}
private void blockClient(String clientKey) {
long blockedUntil = System.currentTimeMillis() + windowMillis;
blockedClients.put(clientKey, blockedUntil);
expiryIndex.computeIfAbsent(blockedUntil, t -> ConcurrentHashMap.newKeySet()).add(clientKey);
}
private void unblockClient(String clientKey, long expiryTime) {
blockedClients.remove(clientKey);
Set<String> clients = expiryIndex.get(expiryTime);
if (clients != null) {
clients.remove(clientKey);
if (clients.isEmpty()) {
expiryIndex.remove(expiryTime);
}
}
}
private void incrementBlockCount(String clientKey) {
blockedByClient.computeIfAbsent(clientKey, k -> new AtomicLong(0)).incrementAndGet();
}
private void cleanupExpiredClients() {
long now = System.currentTimeMillis();
// Получаем все записи, у которых время истечения <= now
NavigableMap<Long, Set<String>> expired = expiryIndex.headMap(now, true);
if (expired.isEmpty()) return;
for (Map.Entry<Long, Set<String>> entry : expired.entrySet()) {
Set<String> clients = entry.getValue();
for (String client : clients) {
blockedClients.remove(client);
}
}
expired.clear(); // очищаем диапазон из индекса
}
private void sendTooManyRequests(RoutingContext context) {
context.response()
.setStatusCode(429)
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("error", "429 Too Many Requests")
.put("message", "Try again later")
.encodePrettily()
);
}
private String getClientKey(RoutingContext context) {
return context.request().remoteAddress().host().replace(':', '.');
}
}

View File

@@ -0,0 +1,115 @@
package su.xserver.iikocon.handler;
import io.vertx.core.Handler;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.net.SocketAddress;
import io.vertx.ext.web.RoutingContext;
import su.xserver.iikocon.service.SettingsService;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
public class SecurityHandler {
private final SettingsService settings;
public SecurityHandler(SettingsService settings) {
this.settings = settings;
}
public Handler<RoutingContext> hostValidator() {
return ctx -> settings.get("allowed_hosts").onComplete(ar -> {
if (ar.succeeded() && ar.result() != null && !ar.result().isEmpty()) {
String allowedHosts = ar.result();
String requestHost = ctx.request().getHeader("Host");
if (requestHost == null) {
ctx.response().setStatusCode(400).end("Bad Request: Missing Host header");
return;
}
String hostWithoutPort = requestHost.split(":")[0];
Set<String> allowedSet = new HashSet<>(Arrays.asList(allowedHosts.split(",")));
if (!allowedSet.contains(hostWithoutPort) && !allowedSet.contains(requestHost)) {
ctx.response().setStatusCode(403).end("Forbidden: Invalid Host header");
return;
}
}
ctx.next();
});
}
public Handler<RoutingContext> cspHeader() {
return ctx -> settings.get("enable_csp").onComplete(ar -> {
if (ar.succeeded() && "true".equals(ar.result())) {
ctx.response().putHeader("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'");
}
ctx.next();
});
}
public Handler<RoutingContext> proxyHeadersHandler() {
return ctx -> settings.get("use_proxy_headers").onComplete(useProxy -> {
if (!useProxy.succeeded() || !"true".equals(useProxy.result())) {
ctx.next();
return;
}
settings.get("trusted_proxies").onComplete(trusted -> {
if (!trusted.succeeded() || trusted.result() == null) {
ctx.next();
return;
}
String trustedProxies = trusted.result();
SocketAddress remoteAddr = ctx.request().remoteAddress();
if (remoteAddr == null) {
ctx.next();
return;
}
String clientIp = remoteAddr.host();
if (isIpTrusted(clientIp, trustedProxies)) {
String realIp = getRealIpFromHeaders(ctx.request());
if (realIp != null) {
ctx.put("realClientIp", realIp);
ctx.put("originalClientIp", clientIp);
}
}
ctx.next();
});
});
}
private boolean isIpTrusted(String ip, String trustedList) {
String[] ips = trustedList.split(",");
for (String trusted : ips) {
trusted = trusted.trim();
if (trusted.contains("/")) {
if (ipMatchesCidr(ip, trusted)) return true;
} else if (ip.equals(trusted)) {
return true;
}
}
return false;
}
private boolean ipMatchesCidr(String ip, String cidr) {
try {
String[] parts = cidr.split("/");
String network = parts[0];
int prefix = Integer.parseInt(parts[1]);
return ip.startsWith(network.substring(0, network.lastIndexOf('.')));
} catch (Exception e) {
return false;
}
}
private String getRealIpFromHeaders(HttpServerRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) {
return xff.split(",")[0].trim();
}
String xri = request.getHeader("X-Real-IP");
if (xri != null && !xri.isEmpty()) {
return xri;
}
return null;
}
}

View File

@@ -1,7 +1,8 @@
package su.xserver.iikocon; package su.xserver.iikocon.handler;
import io.vertx.core.json.JsonObject; import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.RoutingContext;
import su.xserver.iikocon.service.UserService;
public class SetupHandler { public class SetupHandler {
private final UserService userService; private final UserService userService;
@@ -51,8 +52,11 @@ public class SetupHandler {
return; return;
} }
String ip = ctx.request().remoteAddress().host(); String clientIp = ctx.get("realClientIp");
userService.createUser(login, email, password, ip, true).onComplete(cr -> { if (clientIp == null) {
clientIp = ctx.request().remoteAddress().host();
}
userService.createUser(login, email, password, clientIp, true, "admin").onComplete(cr -> {
if (cr.succeeded()) { if (cr.succeeded()) {
ctx.response().setStatusCode(201) ctx.response().setStatusCode(201)
.end(new JsonObject().put("success", true).encode()); .end(new JsonObject().put("success", true).encode());

View File

@@ -0,0 +1,266 @@
package su.xserver.iikocon.iiko;
import io.vertx.core.Future;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.Tuple;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.handler.AdminHandler;
import su.xserver.iikocon.handler.AuthHandler;
import su.xserver.iikocon.service.DataBaseService;
import su.xserver.iikocon.service.RestaurantService;
public class IikoHandler {
private final Logger log = LoggerFactory.getLogger("[IikoHandler]");
private final DataBaseService db;
private final Vertx vertx;
private final RestaurantService restaurantService;
public IikoHandler(Vertx vertx, Router router, DataBaseService db, RestaurantService restaurantService, AuthHandler authHandler) {
this.vertx = vertx;
this.restaurantService = restaurantService;
this.db = db;
createTablesIfNotExist().onFailure(err -> {
log.error("Failed to initialize database", err);
});
router.route("/api/reports/olap/*").handler(authHandler::requireAuth);
router.get("/api/reports/olap/columns").handler(this::getColumns);
router.delete("/api/reports/olap/columns/:fieldKey").handler(AdminHandler::requireAdmin).handler(this::deleteColumn);
router.post("/api/reports/olap/initialize").handler(AdminHandler::requireAdmin).handler(this::postInitialize);
}
private void getColumns(RoutingContext ctx) {
getAllFieldsWithReportAndTags()
.onSuccess(ar -> ctx.response()
.putHeader("Content-Type", "application/json")
.end(ar.encodePrettily()))
.onFailure(err -> ctx.response()
.setStatusCode(500)
.end(err.getMessage()));
}
public void deleteColumn(RoutingContext ctx) {
String fieldKey = ctx.pathParam("fieldKey");
String sql = "DELETE FROM iiko_fields_common WHERE field_key = ?";
db.getPool().preparedQuery(sql)
.execute(Tuple.of(fieldKey))
.onSuccess(res -> {
ctx.end();
})
.onFailure(err -> ctx.response().setStatusCode(500).end(err.getMessage()));
}
private void postInitialize(RoutingContext ctx) {
JsonObject body = ctx.body().asJsonObject();
if (body == null) {
ctx.response()
.setStatusCode(400)
.end("Request body is missing or not a JSON object");
return;
}
if (!body.containsKey("restaurantId") || body.getValue("restaurantId") == null) {
ctx.response()
.setStatusCode(400)
.end("restaurantId is required");
return;
}
Integer restaurantId;
try {
restaurantId = body.getInteger("restaurantId");
if (restaurantId == null) {
throw new IllegalArgumentException("restaurantId must be a number");
}
} catch (ClassCastException e) {
ctx.response()
.setStatusCode(400)
.end("restaurantId must be a valid integer");
return;
}
restaurantService.findById(restaurantId)
.onSuccess(rest -> {
IikoOlapClient iiko = new IikoOlapClient(vertx, rest);
iiko.checkConnection()
.onSuccess(ping -> clearTables()
.onSuccess(data -> {
IikoOlapColumnsImporter importer = new IikoOlapColumnsImporter(iiko, db);
importer.fetchAndStoreAll()
.onSuccess(res -> ctx.end("OK"))
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage()));
})
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage())))
.onFailure(err -> ctx.response().setStatusCode(400).end(err.getMessage()));
})
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage()));
}
public Future<JsonObject> getAllFieldsWithReportAndTags() {
String sql = """
SELECT
fc.field_key,
fc.field_key_normal,
fc.name,
fc.type,
fc.type_normal,
fc.aggregation_allowed,
fc.grouping_allowed,
fc.filtering_allowed,
GROUP_CONCAT(DISTINCT rt.name ORDER BY rt.name SEPARATOR ',') AS report_names,
GROUP_CONCAT(DISTINCT t.tag_name ORDER BY t.tag_name SEPARATOR ',') AS tag_names
FROM iiko_fields_common fc
LEFT JOIN iiko_report_type_fields rtf ON fc.field_id = rtf.field_id
LEFT JOIN iiko_report_types rt ON rtf.report_type_id = rt.report_type_id
LEFT JOIN iiko_field_tags ft ON fc.field_id = ft.field_id
LEFT JOIN iiko_tags t ON ft.tag_id = t.tag_id
GROUP BY fc.field_id
ORDER BY fc.field_key
""";
return db.getPool().query(sql).execute()
.map(rows -> {
JsonArray columnsArray = new JsonArray();
for (Row row : rows) {
String reportNamesStr = row.getString("report_names");
JsonArray reportTypes = new JsonArray();
if (reportNamesStr != null && !reportNamesStr.isBlank()) {
for (String name : reportNamesStr.split(",")) {
reportTypes.add(name.trim());
}
}
String tagNamesStr = row.getString("tag_names");
JsonArray tags = new JsonArray();
if (tagNamesStr != null && !tagNamesStr.isBlank()) {
for (String tag : tagNamesStr.split(",")) {
tags.add(tag.trim());
}
}
JsonObject fieldObj = new JsonObject()
.put("fieldKey", row.getString("field_key"))
.put("fieldKeyNormal", row.getString("field_key_normal"))
.put("reportTypes", reportTypes)
.put("name", row.getString("name"))
.put("type", row.getString("type"))
.put("typeNormal", row.getString("type_normal"))
.put("aggregationAllowed", row.getBoolean("aggregation_allowed"))
.put("groupingAllowed", row.getBoolean("grouping_allowed"))
.put("filteringAllowed", row.getBoolean("filtering_allowed"))
.put("tags", tags);
columnsArray.add(fieldObj);
}
return new JsonObject().put("columns", columnsArray);
});
}
private Future<Void> createTablesIfNotExist() {
String createReportTypes = """
CREATE TABLE IF NOT EXISTS iiko_report_types (
report_type_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createFieldsCommon = """
CREATE TABLE IF NOT EXISTS iiko_fields_common (
field_id INT AUTO_INCREMENT PRIMARY KEY,
field_key VARCHAR(255) NOT NULL UNIQUE,
field_key_normal VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
type_normal VARCHAR(50) NOT NULL,
aggregation_allowed BOOLEAN NOT NULL DEFAULT 0,
grouping_allowed BOOLEAN NOT NULL DEFAULT 0,
filtering_allowed BOOLEAN NOT NULL DEFAULT 0
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createReportTypeFields = """
CREATE TABLE IF NOT EXISTS iiko_report_type_fields (
report_type_id INT NOT NULL,
field_id INT NOT NULL,
PRIMARY KEY (report_type_id, field_id),
FOREIGN KEY (report_type_id) REFERENCES iiko_report_types(report_type_id) ON DELETE CASCADE,
FOREIGN KEY (field_id) REFERENCES iiko_fields_common(field_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createTags = """
CREATE TABLE IF NOT EXISTS iiko_tags (
tag_id INT AUTO_INCREMENT PRIMARY KEY,
tag_name VARCHAR(100) UNIQUE NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createFieldTags = """
CREATE TABLE IF NOT EXISTS iiko_field_tags (
field_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (field_id, tag_id),
FOREIGN KEY (field_id) REFERENCES iiko_fields_common(field_id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES iiko_tags(tag_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String idxKeyNormal = "CREATE INDEX IF NOT EXISTS idx_fields_common_key_normal ON iiko_fields_common(field_key_normal)";
String idxFieldName = "CREATE INDEX IF NOT EXISTS idx_fields_common_name ON iiko_fields_common(name)";
String idxFieldTagsTag = "CREATE INDEX IF NOT EXISTS idx_field_tags_tag_id ON iiko_field_tags(tag_id)";
return db.getPool().query(createReportTypes).execute()
.compose(v -> db.getPool().query(createFieldsCommon).execute())
.compose(v -> db.getPool().query(createReportTypeFields).execute())
.compose(v -> db.getPool().query(createTags).execute())
.compose(v -> db.getPool().query(createFieldTags).execute())
.compose(v -> db.getPool().query(idxKeyNormal).execute())
.compose(v -> db.getPool().query(idxFieldName).execute())
.compose(v -> db.getPool().query(idxFieldTagsTag).execute())
.mapEmpty();
}
private Future<Void> clearTables() {
String sql = """
-- Отключаем проверку внешних ключей
SET FOREIGN_KEY_CHECKS = 0;
-- Удаляем данные из всех таблиц (порядок не важен при отключённой проверке)
DELETE FROM iiko_field_tags;
DELETE FROM iiko_report_type_fields;
DELETE FROM iiko_fields_common;
DELETE FROM iiko_tags;
DELETE FROM iiko_report_types;
-- Сбрасываем счётчики AUTO_INCREMENT (чтобы новые ID начинались с 1)
ALTER TABLE iiko_fields_common AUTO_INCREMENT = 1;
ALTER TABLE iiko_tags AUTO_INCREMENT = 1;
ALTER TABLE iiko_report_types AUTO_INCREMENT = 1;
-- Включаем проверку обратно
SET FOREIGN_KEY_CHECKS = 1;
""";
return db.getPool().query(sql).execute().mapEmpty();
}
}

View File

@@ -0,0 +1,130 @@
package su.xserver.iikocon.iiko;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.WebClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;
public class IikoOlapClient {
private static final Logger log = LoggerFactory.getLogger("[IikoOlapClient]");
private final WebClient webClient;
private final String iikoHost;
private final String iikoLogin;
private final String iikoPassHash;
public IikoOlapClient(Vertx vertx, JsonObject rest) {
this.webClient = WebClient.create(vertx);
this.iikoHost = (rest.getBoolean("https") ? "https://" : "http://") + rest.getString("host") + (rest.getBoolean("https") ? ":443" : ":80");
this.iikoLogin = rest.getString("login");
this.iikoPassHash = rest.getString("password");
}
private Future<String> authenticate() {
Promise<String> promise = Promise.promise();
String url = iikoHost + "/resto/api/auth";
webClient.getAbs(url)
.addQueryParam("login", iikoLogin)
.addQueryParam("pass", iikoPassHash)
.send()
.onSuccess(resp -> {
if (resp.statusCode() == 200) {
String token = resp.bodyAsString();
log.info("Authenticated, token: {}", token);
promise.complete(token);
} else {
promise.fail("Auth failed for " + iikoLogin + ": " + resp.statusCode());
}
})
.onFailure(promise::fail);
return promise.future();
}
private Future<Void> logout(String token) {
if (token == null || token.isEmpty()) {
return Future.succeededFuture();
}
Promise<Void> promise = Promise.promise();
String url = iikoHost + "/resto/api/logout";
webClient.getAbs(url)
.addQueryParam("key", token)
.send()
.onSuccess(resp -> {
log.info(resp.bodyAsString());
promise.complete();
})
.onFailure(err -> {
log.error("Logout request failed: {}", err.getMessage());
promise.complete();
});
return promise.future();
}
public Future<JsonObject> checkConnection() {
Promise<JsonObject> promise = Promise.promise();
long time = System.currentTimeMillis();
authenticate()
.onSuccess(token -> {
logout(token).mapEmpty();
promise.complete(new JsonObject()
.put("success", true)
.put("latency_ms", System.currentTimeMillis() - time)
);
})
.onFailure(promise::fail);
return promise.future();
}
public Future<JsonObject> handleGet(String uri, JsonObject params) {
Promise<JsonObject> promise = Promise.promise();
authenticate()
.onSuccess(token -> {
String url = appendQueryParams(iikoHost + uri, params.put("key", token));
log.info("Request to : {}", url);
webClient.getAbs(url)
.send()
.onSuccess(resp -> {
if (resp.statusCode() == 200) {
JsonObject body = resp.bodyAsJsonObject();
JsonObject data = body.containsKey("data") && body.getValue("data") instanceof JsonObject
? body.getJsonObject("data")
: body;
logout(token).mapEmpty();
promise.complete(data);
} else {
promise.fail("Failed request to " + iikoHost + ": HTTP " + resp.statusCode());
}
})
.onFailure(promise::fail);
})
.onFailure(promise::fail);
return promise.future();
}
private String toQueryString(JsonObject params) {
if (params == null || params.isEmpty()) {
return "";
}
return "?" + params.stream()
.map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + "=" +
URLEncoder.encode(entry.getValue().toString(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
}
private String appendQueryParams(String url, JsonObject params) {
return url + toQueryString(params);
}
}

View File

@@ -0,0 +1,186 @@
package su.xserver.iikocon.iiko;
import io.vertx.core.Future;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.sqlclient.Tuple;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.service.DataBaseService;
import java.util.ArrayList;
import java.util.List;
public class IikoOlapColumnsImporter {
private static final Logger log = LoggerFactory.getLogger("[IikoOlapColumnsImporter]");
private final DataBaseService db;
private final IikoOlapClient iikoOlapClient;
private static final List<String> REPORT_TYPES = List.of("SALES", "TRANSACTIONS", "DELIVERIES");
public IikoOlapColumnsImporter(IikoOlapClient iikoOlapClient, DataBaseService db) {
this.iikoOlapClient = iikoOlapClient;
this.db = db;
}
public Future<Void> fetchAndStoreAll() {
return processAllReportTypesSequentially()
.onSuccess(v -> log.info("All reports imported successfully"))
.onFailure(err -> log.error("Import failed: {}", err.getMessage()));
}
private Future<Void> processAllReportTypesSequentially() {
Future<Void> result = Future.succeededFuture();
for (String reportType : REPORT_TYPES) {
result = result.compose(v -> processOneReportType(reportType));
}
return result;
}
private Future<Void> processOneReportType(String reportType) {
log.info("Processing report type: {}", reportType);
return fetchColumnsFromIiko(reportType)
.compose(columnsJson -> storeColumnsToDb(reportType, columnsJson));
}
private Future<JsonObject> fetchColumnsFromIiko(String reportType) {
return iikoOlapClient.handleGet("/resto/api/v2/reports/olap/columns",
new JsonObject().put("reportType", reportType));
}
private Future<Void> storeColumnsToDb(String reportType, JsonObject columns) {
return getOrCreateReportType(reportType)
.compose(reportTypeId -> {
List<Future<Void>> fieldFutures = new ArrayList<>();
for (String fieldKey : columns.fieldNames()) {
JsonObject fieldDef = columns.getJsonObject(fieldKey);
fieldFutures.add(storeSingleField(reportTypeId, fieldKey, fieldDef));
}
return Future.all(fieldFutures).mapEmpty();
});
}
private Future<Integer> getOrCreateReportType(String reportType) {
String selectSql = "SELECT report_type_id FROM iiko_report_types WHERE name = ?";
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(reportType))
.compose(rows -> {
if (rows.size() > 0) {
return Future.succeededFuture(rows.iterator().next().getInteger("report_type_id"));
} else {
String insertSql = "INSERT INTO iiko_report_types (name, description) VALUES (?, ?)";
return db.getPool().preparedQuery(insertSql)
.execute(Tuple.of(reportType, "OLAP report type: " + reportType))
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(reportType))
.map(rows2 -> rows2.iterator().next().getInteger("report_type_id"))
);
}
});
}
/**
* Сохранить одно поле (без дублирования).
* Сначала получаем/создаём запись в iiko_fields_common,
* затем связываем её с report_type_id через iiko_report_type_fields,
* потом обрабатываем теги.
*/
private Future<Void> storeSingleField(int reportTypeId, String fieldKey, JsonObject fieldDef) {
String fieldKeyNormal = fieldKey.replace('.', '_');
String name = fieldDef.getString("name");
String originalType = fieldDef.getString("type");
String typeNormal = normalizeType(originalType);
boolean aggregationAllowed = fieldDef.getBoolean("aggregationAllowed", false);
boolean groupingAllowed = fieldDef.getBoolean("groupingAllowed", false);
boolean filteringAllowed = fieldDef.getBoolean("filteringAllowed", false);
JsonArray tagsArray = fieldDef.getJsonArray("tags", new JsonArray());
return getOrCreateCommonField(fieldKey, fieldKeyNormal, name, originalType, typeNormal,
aggregationAllowed, groupingAllowed, filteringAllowed)
.compose(fieldId -> linkFieldToReportType(reportTypeId, fieldId)
.compose(v -> processTags(fieldId, tagsArray))
);
}
/**
* Найти или создать поле в iiko_fields_common (по уникальному field_key).
*/
private Future<Integer> getOrCreateCommonField(String fieldKey, String fieldKeyNormal, String name,
String type, String typeNormal,
boolean aggAllowed, boolean groupAllowed, boolean filterAllowed) {
String selectSql = "SELECT field_id FROM iiko_fields_common WHERE field_key = ?";
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(fieldKey))
.compose(rows -> {
if (rows.size() > 0) {
return Future.succeededFuture(rows.iterator().next().getInteger("field_id"));
} else {
String insertSql = """
INSERT INTO iiko_fields_common
(field_key, field_key_normal, name, type, type_normal,
aggregation_allowed, grouping_allowed, filtering_allowed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
return db.getPool().preparedQuery(insertSql)
.execute(Tuple.of(fieldKey, fieldKeyNormal, name, type, typeNormal,
aggAllowed, groupAllowed, filterAllowed))
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(fieldKey))
.map(rows2 -> rows2.iterator().next().getInteger("field_id"))
);
}
});
}
/**
* Привязать поле к типу отчёта (если ещё не привязано).
*/
private Future<Void> linkFieldToReportType(int reportTypeId, int fieldId) {
String sql = "INSERT IGNORE INTO iiko_report_type_fields (report_type_id, field_id) VALUES (?, ?)";
return db.getPool().preparedQuery(sql).execute(Tuple.of(reportTypeId, fieldId)).mapEmpty();
}
/**
* Обработать теги поля (теги одинаковы для всех типов отчётов).
*/
private Future<Void> processTags(int fieldId, JsonArray tags) {
List<Future<Void>> tagFutures = new ArrayList<>();
for (Object tagObj : tags) {
String tagName = tagObj.toString();
tagFutures.add(getOrCreateTag(tagName)
.compose(tagId -> linkFieldTag(fieldId, tagId)));
}
return Future.all(tagFutures).mapEmpty();
}
private Future<Integer> getOrCreateTag(String tagName) {
String selectSql = "SELECT tag_id FROM iiko_tags WHERE tag_name = ?";
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(tagName))
.compose(rows -> {
if (rows.size() > 0) {
return Future.succeededFuture(rows.iterator().next().getInteger("tag_id"));
} else {
String insertSql = "INSERT IGNORE INTO iiko_tags (tag_name) VALUES (?)";
return db.getPool().preparedQuery(insertSql).execute(Tuple.of(tagName))
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(tagName))
.map(rows2 -> rows2.iterator().next().getInteger("tag_id"))
);
}
});
}
private Future<Void> linkFieldTag(int fieldId, int tagId) {
String sql = "INSERT IGNORE INTO iiko_field_tags (field_id, tag_id) VALUES (?, ?)";
return db.getPool().preparedQuery(sql).execute(Tuple.of(fieldId, tagId)).mapEmpty();
}
private String normalizeType(String iikoType) {
if (iikoType == null) return "string";
return switch (iikoType) {
case "ENUM", "STRING", "ID", "ID_STRING" -> "string";
case "DATETIME" -> "datetime";
case "INTEGER", "DURATION_IN_SECONDS" -> "integer";
case "PERCENT", "AMOUNT", "MONEY" -> "decimal";
default -> "string";
};
}
}

View File

@@ -0,0 +1,259 @@
package su.xserver.iikocon.service;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.jdbcclient.JDBCConnectOptions;
import io.vertx.jdbcclient.JDBCPool;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.templates.SqlTemplate;
import su.xserver.iikocon.handler.AdminHandler;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class ExternalDataBaseService {
private final Pool pool;
private final Vertx vertx;
public ExternalDataBaseService(Pool pool, Vertx vertx) {
this.pool = pool;
this.vertx = vertx;
}
public void handleRoute(Router router) {
router.get("/api/admin/database-connections").handler(rc -> this.getAllDataBases().onComplete(ar -> {
if (ar.succeeded()) {
rc.response()
.putHeader("Content-Type", "application/json")
.end(ar.result().encode());
} else {
rc.response().setStatusCode(500).end(ar.cause().getMessage());
}
}));
router.get("/api/admin/database-connections/:id/test").handler(AdminHandler::requireAdmin).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
this.testConnection(id)
.onSuccess(result -> rc.response()
.setStatusCode(200)
.putHeader("Content-Type", "application/json")
.end(result.encode()))
.onFailure(err -> rc.response()
.setStatusCode(500)
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("success", false)
.put("error", err.getMessage())
.encode()));
});
router.post("/api/admin/database-connections").handler(AdminHandler::requireAdmin).handler(rc -> {
JsonObject body = rc.body().asJsonObject();
String name = body.getString("name");
String type = body.getString("type");
String host = body.getString("host");
int port = body.getInteger("port");
String database = body.getString("database");
String user = body.getString("user");
String password = body.getString("password");
if (name == null || type == null || host == null || port < 1 || database == null || user == null || password == null) {
rc.response().setStatusCode(400).end("Missing fields");
return;
}
this.createDataBase(name, type, host, port, database, user, password)
.onSuccess(v -> rc.response().setStatusCode(201).end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/database-connections/:id").handler(AdminHandler::requireAdmin).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
JsonObject body = rc.body().asJsonObject();
String name = body.getString("name");
String type = body.getString("type");
String host = body.getString("host");
int port = body.getInteger("port");
String database = body.getString("database");
String user = body.getString("user");
String password = body.getString("password");
if (name == null || type == null || host == null || port < 1 || database == null || user == null) {
rc.response().setStatusCode(400).end("Missing fields");
return;
}
this.updateDataBase(id, name, type, host, port, database, user, password)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.delete("/api/admin/database-connections/:id").handler(AdminHandler::requireAdmin).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
this.deleteDataBase(id)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
}
public Future<Void> initDatabase() {
String createTable = """
CREATE TABLE IF NOT EXISTS external_database (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
type VARCHAR(40) UNIQUE NOT NULL,
host VARCHAR(255) NOT NULL,
port INT NOT NULL,
database VARCHAR(255) NOT NULL,
user VARCHAR(255) NOT NULL,
password 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<Void> createDataBase(String name, String type, String host, int port, String database, String user, String password) {
Map<String, Object> params = Map.of(
"name", name,
"type", type,
"host", host,
"port", port,
"database", database,
"user", user,
"password", password
);
return SqlTemplate.forUpdate(pool,
"INSERT INTO external_database (name, type, host, port, database, user, password) VALUES (#{name}, #{type}, #{host}, #{port}, #{database}, #{user}, #{password})")
.execute(params)
.mapEmpty();
}
public Future<JsonArray> getAllDataBases() {
return pool.query("SELECT id, name, type, host, port, database, user, password, created, updated FROM external_database ORDER BY id")
.execute()
.map(rows -> {
JsonArray array = new JsonArray();
for (Row row : rows) {
array.add(new JsonObject()
.put("id", row.getInteger("id"))
.put("name", row.getString("name"))
.put("type", row.getString("type"))
.put("host", row.getString("host"))
.put("port", row.getInteger("port"))
.put("database", row.getString("database"))
.put("user", row.getString("user"))
.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<JsonObject> findById(int id) {
return SqlTemplate.forQuery(pool,
"SELECT id, name, type, host, port, database, user, password, created, updated FROM external_database WHERE id = #{id}")
.mapTo(row -> new JsonObject()
.put("id", row.getInteger("id"))
.put("name", row.getString("name"))
.put("type", row.getString("type"))
.put("host", row.getString("host"))
.put("port", row.getInteger("port"))
.put("database", row.getString("database"))
.put("user", row.getString("user"))
.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))
.execute(Collections.singletonMap("id", id))
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
}
public Future<Void> updateDataBase(int id, String name, String type, String host, int port, String database, String user, String password) {
Map<String, Object> params = new HashMap<>();
params.put("id", id);
params.put("name", name);
params.put("type", type);
params.put("host", host);
params.put("port", port);
params.put("database", database);
params.put("user", user);
String sql;
if (password != null && !password.isEmpty()) {
params.put("password", password);
sql = "UPDATE external_database SET name = #{name}, type = #{type}, host = #{host}, port = #{port}, database = #{database}, user = #{user}, password = #{password} WHERE id = #{id}";
} else {
sql = "UPDATE external_database SET name = #{name}, type = #{type}, host = #{host}, port = #{port}, database = #{database}, user = #{user} WHERE id = #{id}";
}
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
}
public Future<Void> deleteDataBase(int id) {
return SqlTemplate.forUpdate(pool, "DELETE FROM external_database WHERE id = #{id}")
.execute(Collections.singletonMap("id", id))
.mapEmpty();
}
public Future<JsonObject> testConnection(int id) {
Promise<JsonObject> promise = Promise.promise();
this.findById(id)
.onSuccess(conn -> {
String jdbcUrl = buildJdbcUrl(conn);
if (jdbcUrl == null) {
promise.fail("Unsupported database type: " + conn.getString("type"));
return;
}
JDBCConnectOptions connectOptions = new JDBCConnectOptions()
.setJdbcUrl(jdbcUrl)
.setDatabase(conn.getString("database"))
.setUser(conn.getString("user"))
.setPassword(conn.getString("password"));
PoolOptions poolOptions = new PoolOptions()
.setMaxSize(1);
Pool pool = JDBCPool.pool(vertx, connectOptions, poolOptions);
long startTime = System.currentTimeMillis();
pool
.query("SELECT 1")
.execute()
.onSuccess(rows -> {
long latency = System.currentTimeMillis() - startTime;
JsonObject result = new JsonObject()
.put("success", true)
.put("latency_ms", latency);
promise.complete(result);
pool.close();
})
.onFailure(err -> promise.fail("Connection failed: " + err.getMessage()));
})
.onFailure(promise::fail);
return promise.future();
}
private String buildJdbcUrl(JsonObject conn) {
return switch (conn.getString("type").toLowerCase()) {
case "mysql" -> String.format("jdbc:mysql://%s:%d",
conn.getString("host"), conn.getInteger("port"));
case "postgres" -> String.format("jdbc:postgresql://%s:%d",
conn.getString("host"), conn.getInteger("port"));
case "clickhouse" ->
String.format("jdbc:clickhouse://%s:%d",
conn.getString("host"), conn.getInteger("port"));
default -> null;
};
}
}

View File

@@ -42,20 +42,20 @@ public class HealthCheckService {
}); });
// Database check // Database check
healthCheckHandler.register("DataBase", future -> { healthCheckHandler.register("database", future -> {
long start = System.currentTimeMillis(); long start = System.currentTimeMillis();
dbService.getPool().query("SELECT 1").execute() dbService.getPool().query("SELECT 1").execute()
.onSuccess(rs -> { .onSuccess(rs -> {
long time = System.currentTimeMillis() - start; long time = System.currentTimeMillis() - start;
JsonObject data = new JsonObject() JsonObject data = new JsonObject()
.put("name", "database") .put("name", "DataBase")
.put("latency_ms", time); .put("latency_ms", time);
future.complete(Status.OK(data)); future.complete(Status.OK(data));
}) })
.onFailure(err -> future.tryFail("DataBase ping failed: " + err.getMessage())); .onFailure(err -> future.tryFail("DataBase ping failed: " + err.getMessage()));
}); });
// Регистрируем endpoint /api/health // Endpoint /api/health
router.get("/api/health").handler(healthCheckHandler); router.get("/api/health").handler(healthCheckHandler);
} }
} }

View File

@@ -1,4 +1,4 @@
package su.xserver.iikocon; package su.xserver.iikocon.service;
import io.vertx.core.Future; import io.vertx.core.Future;
import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonArray;
@@ -7,6 +7,9 @@ import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.Row; import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.templates.SqlTemplate; import io.vertx.sqlclient.templates.SqlTemplate;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -18,61 +21,56 @@ public class RestaurantService {
this.pool = pool; this.pool = pool;
} }
// Хеширование пароля SHA-1
private String hashPassword(String password) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] hash = md.digest(password.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-1 algorithm not found", e);
}
}
public Future<Void> initDatabase() { public Future<Void> initDatabase() {
String createTable = """ String createTable = """
CREATE TABLE IF NOT EXISTS restaurants ( CREATE TABLE IF NOT EXISTS restaurants (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(255) UNIQUE NOT NULL,
login VARCHAR(255) NOT NULL, login VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL, host VARCHAR(255) NOT NULL,
https BOOLEAN DEFAULT FALSE,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) )
"""; """;
return pool.query(createTable).execute().mapEmpty(); return pool.query(createTable).execute().mapEmpty();
} }
public Future<Long> countRestaurant() { public Future<Void> createRestaurant(String name, String login, String password, String host, boolean https) {
return pool.query("SELECT COUNT(*) AS cnt FROM restaurants") String hashedPassword = hashPassword(password);
.execute() Map<String, Object> params = Map.of(
.map(rows -> rows.iterator().next().getLong("cnt")); "name", name,
} "login", login,
"password", hashedPassword,
public Future<Void> createRestaurant(String name, String login, String password, String host) { "host", host,
"https", https
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, return SqlTemplate.forUpdate(pool,
"INSERT INTO restaurants (name, login, password, host) VALUES (#{name}, #{login}, #{password}, #{host})") "INSERT INTO restaurants (name, login, password, host, https) VALUES (#{name}, #{login}, #{password}, #{host}, #{https})")
.execute(params) .execute(params)
.mapEmpty(); .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() { public Future<JsonArray> getAllRestaurants() {
return pool.query("SELECT id, name, login, created, updated, host FROM restaurants ORDER BY id") return pool.query("SELECT id, name, login, created, updated, https, host FROM restaurants ORDER BY id")
.execute() .execute()
.map(rows -> { .map(rows -> {
JsonArray array = new JsonArray(); JsonArray array = new JsonArray();
@@ -85,6 +83,7 @@ public class RestaurantService {
row.getLocalDateTime("created").toString() : null) row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? .put("updated", row.getLocalDateTime("updated") != null ?
row.getLocalDateTime("updated").toString() : null) row.getLocalDateTime("updated").toString() : null)
.put("https", row.getBoolean("https"))
.put("host", row.getString("host"))); .put("host", row.getString("host")));
} }
return array; return array;
@@ -93,12 +92,13 @@ public class RestaurantService {
public Future<JsonObject> findById(int id) { public Future<JsonObject> findById(int id) {
return SqlTemplate.forQuery(pool, return SqlTemplate.forQuery(pool,
"SELECT id, name, login, password, host, created, updated FROM restaurants WHERE id = #{id}") "SELECT id, name, login, password, https, host, created, updated FROM restaurants WHERE id = #{id}")
.mapTo(row -> new JsonObject() .mapTo(row -> new JsonObject()
.put("id", row.getInteger("id")) .put("id", row.getInteger("id"))
.put("name", row.getString("name")) .put("name", row.getString("name"))
.put("login", row.getString("login")) .put("login", row.getString("login"))
.put("password", row.getString("password")) .put("password", row.getString("password"))
.put("https", row.getBoolean("https"))
.put("host", row.getString("host")) .put("host", row.getString("host"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null) .put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null)) .put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null))
@@ -106,24 +106,22 @@ public class RestaurantService {
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null); .map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
} }
public Future<Void> updateRestaurant(int id, String name, String login, String password, String host) { public Future<Void> updateRestaurant(int id, String name, String login, String password, String host, boolean https) {
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("id", id); params.put("id", id);
params.put("name", name); params.put("name", name);
params.put("login", login); params.put("login", login);
params.put("host", host); params.put("host", host);
params.put("https", https);
String sql; String sql;
if (password != null && !password.isEmpty()) { if (password != null && !password.isEmpty()) {
params.put("password", password); String hashedPassword = hashPassword(password);
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, password = #{password}, host = #{host} WHERE id = #{id}"; params.put("password", hashedPassword);
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, password = #{password}, host = #{host}, https = #{https} WHERE id = #{id}";
} else { } else {
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, host = #{host} WHERE id = #{id}"; sql = "UPDATE restaurants SET name = #{name}, login = #{login}, host = #{host}, https = #{https} WHERE id = #{id}";
} }
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
return SqlTemplate.forUpdate(pool, sql)
.execute(params)
.mapEmpty();
} }
public Future<Void> deleteRestaurant(int id) { public Future<Void> deleteRestaurant(int id) {

View File

@@ -1,4 +1,4 @@
package su.xserver.iikocon; package su.xserver.iikocon.service;
import io.vertx.core.Future; import io.vertx.core.Future;
import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonArray;
@@ -6,6 +6,7 @@ import io.vertx.core.json.JsonObject;
import io.vertx.sqlclient.Pool; import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.templates.SqlTemplate; import io.vertx.sqlclient.templates.SqlTemplate;
import java.util.List;
import java.util.Map; import java.util.Map;
public class SettingsService { public class SettingsService {
@@ -29,24 +30,6 @@ public class SettingsService {
.put("type", "textarea") .put("type", "textarea")
.put("rows", 2) .put("rows", 2)
); );
meta.add(new JsonObject()
.put("key", "theme")
.put("label", "Theme")
.put("description", "Default color scheme")
.put("type", "select")
.put("options", new JsonArray()
.add(new JsonObject().put("value", "light").put("label", "Light"))
.add(new JsonObject().put("value", "dark").put("label", "Dark"))
.add(new JsonObject().put("value", "auto").put("label", "Auto (system preference)"))
)
);
meta.add(new JsonObject()
.put("key", "items_per_page")
.put("label", "Items Per Page")
.put("description", "Number of items shown in tables")
.put("type", "number")
.put("required", true)
);
meta.add(new JsonObject() meta.add(new JsonObject()
.put("key", "enable_registration") .put("key", "enable_registration")
.put("label", "Allow Public Registration") .put("label", "Allow Public Registration")
@@ -59,17 +42,6 @@ public class SettingsService {
.put("description", "When enabled, only admins can access the site") .put("description", "When enabled, only admins can access the site")
.put("type", "boolean") .put("type", "boolean")
); );
meta.add(new JsonObject()
.put("key", "default_language")
.put("label", "Default Language")
.put("description", "Interface language")
.put("type", "select")
.put("options", new JsonArray()
.add(new JsonObject().put("value", "en").put("label", "English"))
.add(new JsonObject().put("value", "ru").put("label", "Русский"))
.add(new JsonObject().put("value", "es").put("label", "Español"))
)
);
meta.add(new JsonObject() meta.add(new JsonObject()
.put("key", "session_timeout_minutes") .put("key", "session_timeout_minutes")
.put("label", "Session Timeout (minutes)") .put("label", "Session Timeout (minutes)")
@@ -77,19 +49,39 @@ public class SettingsService {
.put("type", "number") .put("type", "number")
.put("required", true) .put("required", true)
); );
// Безопасность и прокси
meta.add(new JsonObject() meta.add(new JsonObject()
.put("key", "logo_url") .put("key", "use_proxy_headers")
.put("label", "Logo URL") .put("label", "Use Proxy Headers")
.put("description", "Path or URL to custom logo image") .put("description", "Respect X-Forwarded-* headers from trusted proxies")
.put("type", "boolean")
);
meta.add(new JsonObject()
.put("key", "trusted_proxies")
.put("label", "Trusted Proxies")
.put("description", "Comma-separated IP addresses of trusted proxies (e.g., 127.0.0.1,10.0.0.0/8)")
.put("type", "text") .put("type", "text")
); );
meta.add(new JsonObject()
.put("key", "enable_csp")
.put("label", "Enable CSP")
.put("description", "Add Content-Security-Policy header")
.put("type", "boolean")
);
meta.add(new JsonObject()
.put("key", "allowed_hosts")
.put("label", "Allowed Hosts")
.put("description", "Comma-separated list of allowed Host headers (empty = allow all)")
.put("type", "text")
);
return Future.succeededFuture(meta); return Future.succeededFuture(meta);
} }
public Future<JsonObject> getAllWithDefaults() { public Future<JsonObject> getAllWithDefaults() {
return getAll().compose(values -> { return getAll().compose(values -> {
JsonObject result = new JsonObject(); JsonObject result = new JsonObject();
// Получаем метаданные, чтобы знать ключи
return getMetadata().map(meta -> { return getMetadata().map(meta -> {
for (Object item : meta) { for (Object item : meta) {
JsonObject m = (JsonObject) item; JsonObject m = (JsonObject) item;
@@ -107,13 +99,13 @@ public class SettingsService {
return switch (key) { return switch (key) {
case "site_name" -> "Admin Panel"; case "site_name" -> "Admin Panel";
case "site_description" -> ""; case "site_description" -> "";
case "theme" -> "light";
case "items_per_page" -> "20";
case "enable_registration" -> "true"; case "enable_registration" -> "true";
case "maintenance_mode" -> "false"; case "maintenance_mode" -> "false";
case "default_language" -> "en"; case "session_timeout_minutes" -> "120";
case "session_timeout_minutes" -> "60"; case "use_proxy_headers" -> "false";
case "logo_url" -> ""; case "trusted_proxies" -> "127.0.0.1";
case "enable_csp" -> "true";
case "allowed_hosts" -> "";
default -> ""; default -> "";
}; };
} }
@@ -128,13 +120,9 @@ public class SettingsService {
return pool.query(createTable).execute() return pool.query(createTable).execute()
.compose(v -> setIfAbsent("site_name", "Admin Panel")) .compose(v -> setIfAbsent("site_name", "Admin Panel"))
.compose(v -> setIfAbsent("site_description", "Powerful administration dashboard")) .compose(v -> setIfAbsent("site_description", "Powerful administration dashboard"))
.compose(v -> setIfAbsent("theme", "light"))
.compose(v -> setIfAbsent("items_per_page", "20"))
.compose(v -> setIfAbsent("enable_registration", "true")) .compose(v -> setIfAbsent("enable_registration", "true"))
.compose(v -> setIfAbsent("maintenance_mode", "false")) .compose(v -> setIfAbsent("maintenance_mode", "false"))
.compose(v -> setIfAbsent("default_language", "en")) .compose(v -> setIfAbsent("session_timeout_minutes", "120"))
.compose(v -> setIfAbsent("session_timeout_minutes", "60"))
.compose(v -> setIfAbsent("logo_url", "/assets/logo.png"))
.mapEmpty(); .mapEmpty();
} }
@@ -170,4 +158,21 @@ public class SettingsService {
return json; return json;
}); });
} }
// Публичные настройки (для фронтенда)
public Future<JsonObject> getPublicSettings() {
return getAll().map(all -> {
JsonObject publicOnly = new JsonObject();
// Только безопасные для отображения ключи
List<String> publicKeys = List.of(
"site_name", "site_description", "enable_registration"
);
for (String key : publicKeys) {
String val = all.getString(key);
if (val != null) publicOnly.put(key, val);
}
return publicOnly;
});
}
} }

View File

@@ -1,4 +1,4 @@
package su.xserver.iikocon; package su.xserver.iikocon.service;
import io.vertx.core.Future; import io.vertx.core.Future;
import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonArray;
@@ -9,9 +9,7 @@ import io.vertx.sqlclient.Tuple;
import io.vertx.sqlclient.templates.SqlTemplate; import io.vertx.sqlclient.templates.SqlTemplate;
import org.mindrot.jbcrypt.BCrypt; import org.mindrot.jbcrypt.BCrypt;
import java.util.Collections; import java.util.*;
import java.util.HashMap;
import java.util.Map;
public class UserService { public class UserService {
private final Pool pool; private final Pool pool;
@@ -28,6 +26,8 @@ public class UserService {
email VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
active BOOLEAN DEFAULT FALSE, active BOOLEAN DEFAULT FALSE,
role VARCHAR(50) DEFAULT 'user',
language VARCHAR(5) DEFAULT 'en',
ip VARCHAR(45), ip VARCHAR(45),
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
@@ -42,24 +42,28 @@ public class UserService {
.map(rows -> rows.iterator().next().getLong("cnt")); .map(rows -> rows.iterator().next().getLong("cnt"));
} }
public Future<Void> createUser(String login, String email, String password, String ip, boolean active) { public Future<Void> createUser(String login, String email, String password, String ip, boolean active, String role) {
String hash = BCrypt.hashpw(password, BCrypt.gensalt()); String hash = BCrypt.hashpw(password, BCrypt.gensalt());
Map<String, Object> params = Map.of( Map<String, Object> params = Map.of(
"login", login, "login", login,
"email", email, "email", email,
"password", hash, "password", hash,
"ip", ip, "ip", ip,
"active", active "active", active,
"role", role
); );
return SqlTemplate.forUpdate(pool, return SqlTemplate.forUpdate(pool,
"INSERT INTO users (login, email, password, ip, active) VALUES (#{login}, #{email}, #{password}, #{ip}, #{active})") "INSERT INTO users (login, email, password, ip, active, role) VALUES (#{login}, #{email}, #{password}, #{ip}, #{active}, #{role})")
.execute(params) .execute(params)
.mapEmpty(); .mapEmpty();
} }
// Существующий метод оставляем, но он будет создавать неактивного пользователя (active = false) public Future<Void> createUser(String login, String email, String password, String ip, boolean active) {
return createUser(login, email, password, ip, active, "user");
}
public Future<Void> createUser(String login, String email, String password, String ip) { public Future<Void> createUser(String login, String email, String password, String ip) {
return createUser(login, email, password, ip, false); return createUser(login, email, password, ip, false, "user");
} }
public Future<Void> setActive(int id, boolean active) { public Future<Void> setActive(int id, boolean active) {
@@ -68,7 +72,7 @@ public class UserService {
} }
public Future<JsonObject> findByLoginOrEmail(String loginOrEmail) { public Future<JsonObject> findByLoginOrEmail(String loginOrEmail) {
String sql = "SELECT id, login, email, password, active, ip, created, updated FROM users WHERE login = ? OR email = ?"; String sql = "SELECT id, login, email, password, active, role, language, ip, created, updated FROM users WHERE login = ? OR email = ?";
return pool.preparedQuery(sql) return pool.preparedQuery(sql)
.execute(Tuple.of(loginOrEmail, loginOrEmail)) .execute(Tuple.of(loginOrEmail, loginOrEmail))
.map(rows -> { .map(rows -> {
@@ -80,31 +84,8 @@ public class UserService {
}); });
} }
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() { public Future<JsonArray> getAllUsers() {
return pool.query("SELECT id, login, email, active, ip, created, updated FROM users ORDER BY id") return pool.query("SELECT id, login, email, active, role, language, ip, created, updated FROM users ORDER BY id")
.execute() .execute()
.map(rows -> { .map(rows -> {
JsonArray array = new JsonArray(); JsonArray array = new JsonArray();
@@ -114,6 +95,8 @@ public class UserService {
.put("login", row.getString("login")) .put("login", row.getString("login"))
.put("email", row.getString("email")) .put("email", row.getString("email"))
.put("active", row.getBoolean("active")) .put("active", row.getBoolean("active"))
.put("role", row.getString("role"))
.put("language", row.getString("language"))
.put("ip", row.getString("ip")) .put("ip", row.getString("ip"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null) .put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null)); .put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null));
@@ -122,29 +105,84 @@ public class UserService {
}); });
} }
public Future<Void> updateUser(int id, String login, String email, String password, String ip) { public Future<Void> updateUser(int id, String login, String email, String password, String ip, String role) {
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("id", id); params.put("id", id);
params.put("login", login); params.put("login", login);
params.put("email", email); params.put("email", email);
params.put("ip", ip); params.put("ip", ip);
if (role != null) params.put("role", role);
String sql; String sql;
if (password != null && !password.isEmpty()) { if (password != null && !password.isEmpty()) {
String hash = BCrypt.hashpw(password, BCrypt.gensalt()); String hash = BCrypt.hashpw(password, BCrypt.gensalt());
params.put("password", hash); params.put("password", hash);
sql = "UPDATE users SET login = #{login}, email = #{email}, password = #{password}, ip = #{ip} WHERE id = #{id}"; sql = "UPDATE users SET login = #{login}, email = #{email}, password = #{password}, ip = #{ip}"
+ (role != null ? ", role = #{role}" : "") + " WHERE id = #{id}";
} else { } else {
sql = "UPDATE users SET login = #{login}, email = #{email}, ip = #{ip} WHERE id = #{id}"; sql = "UPDATE users SET login = #{login}, email = #{email}, ip = #{ip}"
+ (role != null ? ", role = #{role}" : "") + " WHERE id = #{id}";
} }
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty(); return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
} }
public Future<Void> updateUserIp(int userId, String ip) {
return pool.preparedQuery("UPDATE users SET ip = ? WHERE id = ?")
.execute(Tuple.of(ip, userId))
.mapEmpty();
}
public Future<Void> deleteUser(int id) { public Future<Void> deleteUser(int id) {
return SqlTemplate.forUpdate(pool, "DELETE FROM users WHERE id = #{id}") return SqlTemplate.forUpdate(pool, "DELETE FROM users WHERE id = #{id}")
.execute(Collections.singletonMap("id", id)) .execute(Collections.singletonMap("id", id))
.mapEmpty(); .mapEmpty();
} }
public Future<JsonObject> getProfile(int userId) {
return SqlTemplate.forQuery(pool,
"SELECT id, login, email, role, language, ip, created, updated FROM users WHERE id = #{id}")
.mapTo(row -> new JsonObject()
.put("id", row.getInteger("id"))
.put("login", row.getString("login"))
.put("email", row.getString("email"))
.put("role", row.getString("role"))
.put("language", row.getString("language"))
.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))
.execute(Map.of("id", userId))
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
}
public Future<Void> updateProfile(int userId, String email, String password, String language) {
Map<String, Object> params = new HashMap<>();
params.put("id", userId);
List<String> setClauses = new ArrayList<>();
if (email != null) {
setClauses.add("email = #{email}");
params.put("email", email);
}
if (password != null && !password.isEmpty()) {
String hash = BCrypt.hashpw(password, BCrypt.gensalt());
setClauses.add("password = #{password}");
params.put("password", hash);
}
if (language != null) {
setClauses.add("language = #{language}");
params.put("language", language);
}
if (setClauses.isEmpty()) {
return Future.succeededFuture();
}
String sql = "UPDATE users SET " + String.join(", ", setClauses) + " WHERE id = #{id}";
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
}
public boolean checkPassword(String plain, String hash) { public boolean checkPassword(String plain, String hash) {
try { try {
return BCrypt.checkpw(plain, hash); return BCrypt.checkpw(plain, hash);
@@ -158,8 +196,10 @@ public class UserService {
.put("id", row.getInteger("id")) .put("id", row.getInteger("id"))
.put("login", row.getString("login")) .put("login", row.getString("login"))
.put("email", row.getString("email")) .put("email", row.getString("email"))
.put("password", row.getString("password")) // ДОБАВИТЬ ЭТУ СТРОКУ .put("password", row.getString("password"))
.put("active", row.getBoolean("active")) .put("active", row.getBoolean("active"))
.put("role", row.getString("role"))
.put("language", row.getString("language"))
.put("ip", row.getString("ip")) .put("ip", row.getString("ip"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null) .put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null); .put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null);

View File

@@ -0,0 +1,41 @@
package su.xserver.iikocon.test;
import io.vertx.core.Vertx;
import io.vertx.jdbcclient.JDBCConnectOptions;
import io.vertx.jdbcclient.JDBCPool;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ClickHouseJDBCExample {
private static final Logger log = LoggerFactory.getLogger(ClickHouseJDBCExample.class);
public static void main(String[] args) {
Vertx vertx = Vertx.vertx();
JDBCConnectOptions connectOptions = new JDBCConnectOptions()
.setJdbcUrl("jdbc:clickhouse://dl-import.aramagedec.ru:8123")
.setDatabase("test")
.setUser("clickhouse_admin")
.setPassword("7002ITinsta11");
PoolOptions poolOptions = new PoolOptions()
.setMaxSize(16);
Pool pool = JDBCPool.pool(vertx, connectOptions, poolOptions);
pool
.query("SELECT 1")
.execute()
.onSuccess(rows -> {
rows.forEach(row -> log.info(row.toJson().encodePrettily()));
vertx.close();
})
.onFailure(err -> {
log.error(err.getMessage());
vertx.close();
});
}
}

View File

@@ -0,0 +1,21 @@
package su.xserver.iikocon.test;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class DateRangeSetup {
public static void main(String[] args) {
// Вычисление dateFrom и dateTo
LocalDate today = LocalDate.now();
LocalDate dateFrom = today.minusDays(7);
// Форматирование дат в YYYY-MM-DD
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String formattedDateFrom = dateFrom.format(formatter);
String formattedDateTo = today.format(formatter);
System.out.println("dateFrom=" + formattedDateFrom);
System.out.println("dateTo=" + formattedDateTo);
}
}

View File

@@ -18,5 +18,10 @@
"password": null, "password": null,
"maxPoolSize": 6, "maxPoolSize": 6,
"maxWaitingHandlers": 6 "maxWaitingHandlers": 6
},
"pma": {
"enabled": false,
"basePath": "/pma",
"upstream": "http://localhost:80/"
} }
} }