Compare commits
29 Commits
0bca35d015
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a406af54bd | |||
| 1ca4c90b88 | |||
| a61c527ef9 | |||
| f39d9ff11e | |||
| c801783779 | |||
| 50d4ea10c6 | |||
| 0836f8e9e9 | |||
| e7f135e8c1 | |||
| 664092f415 | |||
| 38cc75a688 | |||
| 7a60bb15fe | |||
| 43b57bdb0f | |||
| 05076eb367 | |||
| 316d06b1d2 | |||
| a68f02bab4 | |||
| aad6ba3747 | |||
| 1c7e05f6a3 | |||
| ff46a37956 | |||
| c47dad2af8 | |||
| 82a932dd2b | |||
| b9d1afad42 | |||
| 1d8a436106 | |||
| b7875bb623 | |||
| fc96a95335 | |||
| f16a830eb2 | |||
| f3e105bbc8 | |||
| ec0671c5e8 | |||
| fd3cbb019f | |||
| c47542bef3 |
@@ -1,2 +1,5 @@
|
||||
# iiko-connector
|
||||
|
||||
* `Числовые` → `Агрегация`
|
||||
* `Категории` → `Группировка {ROW / COLUMN}`
|
||||
* `Фильтры` → `Фильтрация`
|
||||
|
||||
@@ -4,12 +4,12 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent.*
|
||||
plugins {
|
||||
java
|
||||
application
|
||||
id("com.gradleup.shadow") version "9.2.2"
|
||||
id("com.gradleup.shadow") version "9.4.1"
|
||||
id("com.github.node-gradle.node") version "7.1.0"
|
||||
}
|
||||
|
||||
node {
|
||||
version.set("22.19.0") // версия Node.js
|
||||
version.set("24.15.0") // версия Node.js
|
||||
npmVersion.set("11.12.1") // версия npm
|
||||
download.set(true) // автоматически скачать Node.js
|
||||
workDir.set(file("${project.projectDir}/.gradle/nodejs"))
|
||||
@@ -24,7 +24,7 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
val vertxVersion = "5.0.10"
|
||||
val vertxVersion = "5.0.11"
|
||||
|
||||
val mainVerticleName = "su.xserver.iikocon.MainVerticle"
|
||||
val launcherClassName = "io.vertx.launcher.application.VertxApplication"
|
||||
@@ -37,6 +37,7 @@ dependencies {
|
||||
implementation(platform("io.vertx:vertx-stack-depchain:$vertxVersion"))
|
||||
implementation("io.vertx:vertx-launcher-application")
|
||||
implementation("io.vertx:vertx-web-client")
|
||||
implementation("io.vertx:vertx-web-proxy")
|
||||
implementation("io.vertx:vertx-config")
|
||||
implementation("io.vertx:vertx-sql-client-templates")
|
||||
implementation("io.vertx:vertx-health-check")
|
||||
@@ -48,16 +49,25 @@ dependencies {
|
||||
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind")
|
||||
|
||||
// https://mvnrepository.com/artifact/org.mindrot/jbcrypt
|
||||
// Source: https://mvnrepository.com/artifact/org.mindrot/jbcrypt
|
||||
implementation("org.mindrot:jbcrypt:0.4")
|
||||
// https://mvnrepository.com/artifact/org.slf4j/slf4j-api
|
||||
// Source: https://mvnrepository.com/artifact/org.slf4j/slf4j-api
|
||||
implementation("org.slf4j:slf4j-api:2.0.17")
|
||||
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j2-impl
|
||||
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.25.3")
|
||||
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
|
||||
implementation("org.apache.logging.log4j:log4j-core:2.25.3")
|
||||
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api
|
||||
implementation("org.apache.logging.log4j:log4j-api:2.25.3")
|
||||
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j2-impl
|
||||
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.25.4")
|
||||
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
|
||||
implementation("org.apache.logging.log4j:log4j-core:2.25.4")
|
||||
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api
|
||||
implementation("org.apache.logging.log4j:log4j-api:2.25.4")
|
||||
|
||||
implementation("io.vertx:vertx-jdbc-client")
|
||||
|
||||
// Source: https://mvnrepository.com/artifact/com.clickhouse/clickhouse-jdbc
|
||||
implementation("com.clickhouse:clickhouse-jdbc:0.9.8")
|
||||
// Source: https://mvnrepository.com/artifact/com.mysql/mysql-connector-j
|
||||
implementation("com.mysql:mysql-connector-j:9.7.0")
|
||||
// Source: https://mvnrepository.com/artifact/org.postgresql/postgresql
|
||||
implementation("org.postgresql:postgresql:42.7.11")
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,13 @@ services:
|
||||
environment:
|
||||
PMA_HOST: iiko-db
|
||||
PMA_PORT: 3306
|
||||
PMA_USER: root
|
||||
PMA_PASSWORD: DVjXT_kew508
|
||||
UPLOAD_LIMIT: 10M
|
||||
# PMA_ABSOLUTE_URI: https://phpmyadmin.dev.xserver.su/
|
||||
PMA_ABSOLUTE_URI: https://iiko-app.dev.xserver.su/phpmyadmin/
|
||||
TZ: Europe/Moscow
|
||||
ports:
|
||||
- "7102:80"
|
||||
# ports:
|
||||
# - "7102:80"
|
||||
|
||||
iiko-redis:
|
||||
image: redis:latest
|
||||
@@ -75,3 +77,9 @@ services:
|
||||
REDIS__HOST: iiko-redis
|
||||
REDIS__PORT: 6379
|
||||
SERVER__PORT: 7104
|
||||
PMA__ENABLED: true
|
||||
PMA__BASE_PATH: /phpmyadmin
|
||||
PMA__UPSTREAM: http://iiko-pma:80/
|
||||
|
||||
volumes:
|
||||
- $PWD/app/logs:/app/logs
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"axios": "^1.15.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-i18n": "^11.4.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -22,7 +23,7 @@
|
||||
"postcss": "^8.5.9",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.3",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-vue-devtools": "^8.1.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -19,3 +19,20 @@
|
||||
opacity: 0;
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -1,100 +1,163 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Sidebar -->
|
||||
<aside class="fixed inset-y-0 left-0 w-64 bg-white border-r border-gray-200">
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 bg-white border-r border-gray-200 transition-all duration-300 z-20"
|
||||
:class="sidebarCollapsed ? 'w-16' : 'w-64'"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center h-16 px-6 border-b border-gray-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Logo / Toggle Button -->
|
||||
<div class="flex items-center h-16 px-4 border-b border-gray-200" :class="sidebarCollapsed ? 'justify-center' : 'justify-between'">
|
||||
<div v-if="!sidebarCollapsed" class="flex items-center space-x-2">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gray-900">AdminPanel</span>
|
||||
<span class="text-xl font-bold text-gray-900">{{ settings.siteName }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleSidebar"
|
||||
class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
:class="sidebarCollapsed ? 'mx-auto' : ''"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="!sidebarCollapsed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||
<nav class="flex-1 px-2 py-6 space-y-1 overflow-y-auto">
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors group"
|
||||
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/dashboard' }"
|
||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||
:class="[
|
||||
route.path === '/dashboard' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||
]"
|
||||
:title="sidebarCollapsed ? t('app.dashboard') : ''"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Dashboard
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.dashboard') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="userStore.role === 'admin'"
|
||||
to="/users"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/users' }"
|
||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||
:class="[
|
||||
route.path === '/users' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||
]"
|
||||
:title="sidebarCollapsed ? t('app.users') : ''"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
Users
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.users') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/restaurants"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/restaurants' }"
|
||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||
:class="[
|
||||
route.path === '/restaurants' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||
]"
|
||||
:title="sidebarCollapsed ? t('app.restaurants') : ''"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Restaurants
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/settings"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/settings' }"
|
||||
v-if="userStore.role === 'admin'"
|
||||
to="/olap-columns"
|
||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||
:class="[
|
||||
route.path === '/olap-columns' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||
]"
|
||||
:title="sidebarCollapsed ? t('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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="p-4 border-t border-gray-200">
|
||||
<!-- User Info (collapsed aware) -->
|
||||
<div v-if="!sidebarCollapsed" class="p-4 border-t border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white font-semibold">
|
||||
{{ userInitials }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ userName }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">Administrator</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ userStore.role === 'admin' ? t('app.administrator') : t('app.user') }}</p>
|
||||
</div>
|
||||
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-2 border-t border-gray-200 flex justify-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white font-semibold text-sm">
|
||||
{{ userInitials }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="ml-64">
|
||||
<!-- Header -->
|
||||
<header class="bg-white border-b border-gray-200">
|
||||
<div class="flex items-center justify-between h-16 px-8">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">{{ pageTitle }}</h1>
|
||||
<main class="transition-all duration-300" :class="sidebarCollapsed ? 'ml-16' : 'ml-64'">
|
||||
<!-- Header (без заголовка) -->
|
||||
<header class="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div class="flex items-center justify-end h-16 px-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
:placeholder="t('app.search')"
|
||||
class="w-64 px-4 py-2 pl-10 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -103,12 +166,42 @@
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<button class="relative p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<button class="relative p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.notifications')">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
<!-- User actions -->
|
||||
<div class="flex items-center space-x-1 border-l pl-4 ml-2">
|
||||
<button @click="toggleLanguage" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.language')">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
</button>
|
||||
<router-link to="/profile" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.profile')">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</router-link>
|
||||
<a
|
||||
href="/phpmyadmin/"
|
||||
v-if="userStore.role === 'admin'"
|
||||
target="_self"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
:title="t('app.database')"
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
</a>
|
||||
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.logout')">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -118,37 +211,87 @@
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<!-- Notification Toast -->
|
||||
<Transition name="slide">
|
||||
<div v-if="notification.show" class="fixed bottom-4 right-4 z-50 flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
|
||||
<svg v-if="notification.type === 'success'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ notification.message }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
|
||||
const { notification, showNotification } = useNotification()
|
||||
const settings = useSettingsStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userName = ref('Loading...')
|
||||
const userLogin = ref('')
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/me')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
userLogin.value = data.login
|
||||
userName.value = data.login // или можно сделать красивое отображение
|
||||
}
|
||||
} catch (e) {
|
||||
userName.value = 'User'
|
||||
}
|
||||
const userName = computed(() => userStore.login || 'User')
|
||||
const userInitials = computed(() => (userName.value[0] || 'U').toUpperCase())
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = 'admin_sidebar_collapsed'
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY)
|
||||
if (saved !== null) sidebarCollapsed.value = saved === 'true'
|
||||
})
|
||||
|
||||
const userInitials = computed(() => {
|
||||
return (userName.value[0] || 'U').toUpperCase()
|
||||
})
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarCollapsed.value))
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/logout', { method: 'POST' })
|
||||
userStore.clear()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
async function toggleLanguage() {
|
||||
const newLang = locale.value === 'en' ? 'ru' : 'en'
|
||||
if (userStore.id) {
|
||||
const ok = await userStore.updateProfile({ language: newLang })
|
||||
if (ok) {
|
||||
locale.value = newLang
|
||||
localStorage.setItem('locale', newLang)
|
||||
} else {
|
||||
showNotification('profile.updateError', 'error');
|
||||
// В случае ошибки всё равно меняем локаль, но не сохраняем в БД
|
||||
locale.value = newLang
|
||||
localStorage.setItem('locale', newLang)
|
||||
}
|
||||
} else {
|
||||
// Для неавторизованных просто сохраняем в localStorage
|
||||
locale.value = newLang
|
||||
localStorage.setItem('locale', newLang)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
43
frontend/src/composables/useNotification.ts
Normal file
43
frontend/src/composables/useNotification.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type NotificationType = 'success' | 'error'
|
||||
|
||||
interface Notification {
|
||||
show: boolean
|
||||
type: NotificationType
|
||||
message: string
|
||||
}
|
||||
|
||||
const notification = ref<Notification>({
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
})
|
||||
|
||||
let timeoutId: number | null = null
|
||||
|
||||
export function useNotification() {
|
||||
const { t } = useI18n()
|
||||
|
||||
const showNotification = (messageKey: string, type: NotificationType = 'success', params?: Record<string, any>) => {
|
||||
const message = params ? t(messageKey, params) : t(messageKey)
|
||||
|
||||
// Очищаем предыдущий таймер, чтобы уведомление не закрылось раньше времени
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
|
||||
notification.value = { show: true, type, message }
|
||||
timeoutId = window.setTimeout(() => {
|
||||
notification.value.show = false
|
||||
timeoutId = null
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
return {
|
||||
notification: readonly(notification),
|
||||
showNotification
|
||||
}
|
||||
}
|
||||
264
frontend/src/locales/en.json
Normal file
264
frontend/src/locales/en.json
Normal 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."
|
||||
}
|
||||
}
|
||||
264
frontend/src/locales/ru.json
Normal file
264
frontend/src/locales/ru.json
Normal 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": "Не удалось удалить подключение к БД."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,54 @@
|
||||
// src/main.ts
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import '@/style.css'
|
||||
import { useSettingsStore } from './stores/settings'
|
||||
import { useUserStore } from './stores/user'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from '@/locales/en.json'
|
||||
import ru from '@/locales/ru.json'
|
||||
|
||||
// Функция определения языка браузера
|
||||
function getBrowserLocale(): string {
|
||||
const browserLang = navigator.language.split('-')[0]
|
||||
return browserLang === 'ru' ? 'ru' : 'en'
|
||||
}
|
||||
|
||||
// Получаем сохраненный язык из localStorage (для неавторизованных)
|
||||
const storedLocale = localStorage.getItem('locale')
|
||||
const initialLocale = storedLocale || getBrowserLocale()
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages: { en, ru }
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
app.use(i18n)
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Загружаем настройки и профиль
|
||||
Promise.all([
|
||||
settingsStore.loadSettings(),
|
||||
userStore.fetchProfile().catch(() => {})
|
||||
]).then(() => {
|
||||
// Если пользователь авторизован – используем язык из профиля
|
||||
if (userStore.id && userStore.language) {
|
||||
i18n.global.locale.value = userStore.language
|
||||
// Сохраняем в localStorage для синхронизации (опционально)
|
||||
localStorage.setItem('locale', userStore.language)
|
||||
} else {
|
||||
// Для неавторизованных – сохраняем текущий язык в localStorage
|
||||
localStorage.setItem('locale', i18n.global.locale.value)
|
||||
}
|
||||
app.mount('#app')
|
||||
})
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Login from '../views/auth/Login.vue'
|
||||
import Setup from '../views/auth/Setup.vue'
|
||||
import Register from '../views/auth/Register.vue'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
import Users from '../views/Users.vue'
|
||||
import Restaurants from '../views/Restaurants.vue'
|
||||
import AdminSettings from '../views/AdminSettings.vue'
|
||||
import NotFound from '../views/NotFound.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import Login from '@/views/auth/Login.vue'
|
||||
import Setup from '@/views/auth/Setup.vue'
|
||||
import Register from '@/views/auth/Register.vue'
|
||||
import Dashboard from '@/views/Dashboard.vue'
|
||||
import Users from '@/views/Users.vue'
|
||||
import Restaurants from '@/views/Restaurants.vue'
|
||||
import 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 = [
|
||||
{ path: '/login', component: Login, meta: { title: 'Login' } },
|
||||
{ path: '/register', component: Register, meta: { title: 'Register' } },
|
||||
{ path: '/setup', component: Setup, meta: { title: 'Setup' } },
|
||||
{
|
||||
path: '/login',
|
||||
component: Login,
|
||||
meta: { title: 'Login', requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
component: Register,
|
||||
meta: { title: 'Register', requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
component: Setup,
|
||||
meta: { title: 'Setup', requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
@@ -24,7 +41,7 @@ const routes = [
|
||||
{
|
||||
path: '/users',
|
||||
component: Users,
|
||||
meta: { requiresAuth: true, title: 'Users' }
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' }
|
||||
},
|
||||
{
|
||||
path: '/restaurants',
|
||||
@@ -32,66 +49,76 @@ const routes = [
|
||||
meta: { requiresAuth: true, title: 'Restaurants' }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: AdminSettings,
|
||||
meta: { requiresAuth: true, title: 'Settings' }
|
||||
path: '/olap-columns',
|
||||
component: OlapColumnsView,
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'OlapColumns' }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: NotFound,
|
||||
meta: { title: 'Page Not Found', requiresAuth: false }
|
||||
path: '/database-connections',
|
||||
component: DBConnections,
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Database Connections' }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: AdminSettings,
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
component: Profile,
|
||||
meta: { requiresAuth: true, title: 'Profile' }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: NotFound,
|
||||
meta: { title: 'Page Not Found', requiresAuth: false }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
const router = createRouter({ history: createWebHistory(), routes })
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// Update page title
|
||||
document.title = `${to.meta.title || 'Admin Panel'} | AdminPanel`
|
||||
const settings = useSettingsStore()
|
||||
if (!settings.siteName) await settings.loadSettings()
|
||||
|
||||
// Check if setup is needed
|
||||
document.title = to.meta.title ? `${to.meta.title} | ${settings.siteName}` : settings.siteName
|
||||
|
||||
// Проверка необходимости установки (setup)
|
||||
try {
|
||||
const statusRes = await fetch('/api/status')
|
||||
const status = await statusRes.json()
|
||||
|
||||
if (status.needsSetup && to.path !== '/setup') {
|
||||
next('/setup')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check status', e)
|
||||
} catch (e) { console.error('Failed to check status', e) }
|
||||
|
||||
const userStore = useUserStore()
|
||||
// Если профиль ещё не загружен – загружаем
|
||||
if (userStore.role === '') {
|
||||
await userStore.fetchProfile()
|
||||
}
|
||||
|
||||
if (to.path === '/login') {
|
||||
try {
|
||||
const meRes = await fetch('/api/admin/me');
|
||||
if (meRes.ok) {
|
||||
next('/dashboard');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// игнорируем ошибку, продолжаем
|
||||
}
|
||||
// Если уже залогинены и пытаемся зайти на login/register – редирект на дашборд
|
||||
if (userStore.id && (to.path === '/login' || to.path === '/register')) {
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
// Проверка доступности регистрации
|
||||
if (to.path === '/register' && !settings.enableRegistration) {
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
|
||||
|
||||
if (requiresAuth) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/me')
|
||||
if (!res.ok) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
} catch {
|
||||
next('/login')
|
||||
}
|
||||
if (requiresAuth && !userStore.id) {
|
||||
next('/login')
|
||||
} else if (requiresAdmin && userStore.role !== 'admin') {
|
||||
next('/dashboard')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
24
frontend/src/stores/settings.ts
Normal file
24
frontend/src/stores/settings.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const siteName = ref('Admin Panel')
|
||||
const siteDescription = ref('')
|
||||
const enableRegistration = ref(true)
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
siteName.value = data.site_name || 'Admin Panel'
|
||||
siteDescription.value = data.site_description || ''
|
||||
enableRegistration.value = data.enable_registration !== 'false'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings', e)
|
||||
}
|
||||
}
|
||||
|
||||
return { siteName, siteDescription, enableRegistration, loadSettings }
|
||||
})
|
||||
52
frontend/src/stores/user.ts
Normal file
52
frontend/src/stores/user.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const id = ref<number | null>(null)
|
||||
const login = ref('')
|
||||
const email = ref('')
|
||||
const role = ref('')
|
||||
const language = ref('en')
|
||||
|
||||
async function fetchProfile() {
|
||||
try {
|
||||
const res = await fetch('/api/profile')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
id.value = data.id
|
||||
login.value = data.login
|
||||
email.value = data.email
|
||||
role.value = data.role
|
||||
language.value = data.language || 'en'
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load profile', e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function updateProfile(updates: { email?: string; password?: string; language?: string }) {
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
if (res.ok) {
|
||||
if (updates.language) language.value = updates.language
|
||||
if (updates.email) email.value = updates.email
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function clear() {
|
||||
id.value = null
|
||||
login.value = ''
|
||||
email.value = ''
|
||||
role.value = ''
|
||||
language.value = 'en'
|
||||
}
|
||||
|
||||
return { id, login, email, role, language, fetchProfile, updateProfile, clear }
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="card">
|
||||
<h1 class="text-2xl font-bold mb-6">Application Settings</h1>
|
||||
<h1 class="text-2xl font-bold mb-6">{{ t('settings.title') }}</h1>
|
||||
<form @submit.prevent="saveSettings" class="space-y-6 max-w-2xl">
|
||||
<div v-for="field in meta" :key="field.key" class="border-b border-gray-200 pb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
@@ -54,22 +54,22 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" @click="loadData" class="btn-secondary">Reset</button>
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
<button type="button" @click="loadData" class="btn-secondary">{{ t('settings.reset') }}</button>
|
||||
<button type="submit" class="btn-primary">{{ t('settings.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="message" class="mt-4 p-3 rounded-lg" :class="messageClass">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import AppLayout from '../components/Layout/AppLayout.vue';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
|
||||
const { showNotification } = useNotification()
|
||||
const { t } = useI18n()
|
||||
interface FieldMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -82,24 +82,22 @@ interface FieldMeta {
|
||||
|
||||
const meta = ref<FieldMeta[]>([]);
|
||||
const values = ref<Record<string, string>>({});
|
||||
const message = ref('');
|
||||
const messageClass = ref('');
|
||||
|
||||
async function loadMeta() {
|
||||
const res = await fetch('/api/settings/meta');
|
||||
const res = await fetch('/api/admin/settings/meta');
|
||||
if (res.ok) {
|
||||
meta.value = await res.json();
|
||||
} else {
|
||||
showMessage('Failed to load settings metadata', 'bg-red-50 text-red-800');
|
||||
showNotification('settings.loadMetaError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadValues() {
|
||||
const res = await fetch('/api/settings/all');
|
||||
const res = await fetch('/api/admin/settings');
|
||||
if (res.ok) {
|
||||
values.value = await res.json();
|
||||
} else {
|
||||
showMessage('Failed to load settings values', 'bg-red-50 text-red-800');
|
||||
showNotification('settings.loadMetaError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,22 +113,14 @@ async function saveSettings() {
|
||||
body: JSON.stringify(values.value),
|
||||
});
|
||||
if (res.ok) {
|
||||
showMessage('Settings saved successfully', 'bg-green-50 text-green-800');
|
||||
showNotification('settings.saveSuccess', 'success');
|
||||
} else {
|
||||
showMessage('Failed to save settings', 'bg-red-50 text-red-800');
|
||||
showNotification('settings.saveError', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('Network error', 'bg-red-50 text-red-800');
|
||||
showNotification('common.networkError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(text: string, cssClass: string) {
|
||||
message.value = text;
|
||||
messageClass.value = cssClass;
|
||||
setTimeout(() => {
|
||||
message.value = '';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
384
frontend/src/views/DBConnections.vue
Normal file
384
frontend/src/views/DBConnections.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('dbConnections.pageName') }}</h1>
|
||||
<button @click="openModal('create')" class="btn-primary flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('dbConnections.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.type') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.host') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.port') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.database') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.user') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr v-for="conn in connections" :key="conn.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ conn.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ conn.name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span :class="getTypeBadgeClass(conn.type)" class="px-2 py-1 rounded-full text-xs font-medium">
|
||||
{{ getTypeLabel(conn.type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.host }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.port }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.database }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.user }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(conn.created) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-3">
|
||||
<button @click="testConnection(conn)" :disabled="conn.testing" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50" :title="t('dbConnections.test')">
|
||||
<svg v-if="!conn.testing" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="openModal('edit', conn)" class="text-blue-600 hover:text-blue-800 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(conn.id)" class="text-red-600 hover:text-red-800 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span v-if="conn.testResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
|
||||
{{ conn.testResult }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="connections.length === 0">
|
||||
<td colspan="9" class="px-6 py-12 text-center text-gray-500">{{ t('dbConnections.noConnections') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно создания/редактирования -->
|
||||
<Transition name="fade">
|
||||
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
|
||||
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitConnection" class="p-6 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }} *</label>
|
||||
<input v-model="form.name" type="text" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.type') }} *</label>
|
||||
<select v-model="form.type" required class="input-field">
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="clickhouse">ClickHouse</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.host') }} *</label>
|
||||
<input v-model="form.host" type="text" required class="input-field" placeholder="localhost or IP" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.port') }} *</label>
|
||||
<input v-model="form.port" type="number" required class="input-field" placeholder="3306, 5432, 8123..." />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.database') }} *</label>
|
||||
<input v-model="form.database" type="text" required class="input-field" placeholder="database name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.user') }} *</label>
|
||||
<input v-model="form.user" type="text" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.password') }}</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
:required="modalMode === 'create'"
|
||||
type="password"
|
||||
class="input-field"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button type="button" @click="closeModal" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Модальное окно подтверждения удаления -->
|
||||
<Transition name="fade">
|
||||
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||
<div class="p-6 text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('dbConnections.delete') }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">{{ t('dbConnections.deleteConfirmation') }}</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button @click="deleteConnection(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useNotification } from '@/composables/useNotification';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
type Connection = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'mysql' | 'postgres' | 'clickhouse';
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
created: string;
|
||||
testing?: boolean;
|
||||
testResult?: string | null;
|
||||
};
|
||||
|
||||
const connections = ref<Connection[]>([]);
|
||||
const modalOpen = ref(false);
|
||||
const modalMode = ref<'create' | 'edit'>('create');
|
||||
const form = ref({
|
||||
id: null as number | null,
|
||||
name: '',
|
||||
type: 'mysql' as 'mysql' | 'postgres' | 'clickhouse',
|
||||
host: '',
|
||||
port: 3306,
|
||||
database: '',
|
||||
user: '',
|
||||
password: ''
|
||||
});
|
||||
const modalTitle = ref('');
|
||||
const deleteConfirm = ref({ show: false, id: null as number | null });
|
||||
|
||||
// Загрузка списка подключений
|
||||
async function loadConnections() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/database-connections');
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
connections.value = data.map((c: any) => ({
|
||||
...c,
|
||||
testing: false,
|
||||
testResult: null
|
||||
}));
|
||||
} catch (e) {
|
||||
showNotification('dbConnections.loadError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
// Тестирование соединения
|
||||
async function testConnection(conn: Connection) {
|
||||
conn.testing = true;
|
||||
conn.testResult = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/database-connections/${conn.id}/test`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
conn.testResult = `${data.latency_ms} ms`;
|
||||
showNotification('dbConnections.testSuccess', 'success', { latency: data.latency_ms });
|
||||
} else {
|
||||
const errorText = data.error || t('dbConnections.testUnknownError');
|
||||
showNotification('dbConnections.testError', 'error', { error: errorText });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showNotification('dbConnections.testNetworkError', 'error', { error: error.message });
|
||||
} finally {
|
||||
conn.testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательные функции для отображения типа
|
||||
function getTypeLabel(type: string) {
|
||||
const labels: Record<string, string> = {
|
||||
mysql: 'MySQL',
|
||||
postgres: 'PostgreSQL',
|
||||
clickhouse: 'ClickHouse'
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
function getTypeBadgeClass(type: string) {
|
||||
const classes: Record<string, string> = {
|
||||
mysql: 'bg-blue-100 text-blue-800',
|
||||
postgres: 'bg-indigo-100 text-indigo-800',
|
||||
clickhouse: 'bg-amber-100 text-amber-800'
|
||||
};
|
||||
return classes[type] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
function openModal(mode: 'create' | 'edit', conn: Connection | null = null) {
|
||||
modalMode.value = mode;
|
||||
if (mode === 'create') {
|
||||
form.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
type: 'mysql',
|
||||
host: '',
|
||||
port: 3306,
|
||||
database: '',
|
||||
user: '',
|
||||
password: ''
|
||||
};
|
||||
modalTitle.value = t('dbConnections.add');
|
||||
} else if (conn) {
|
||||
form.value = {
|
||||
id: conn.id,
|
||||
name: conn.name,
|
||||
type: conn.type,
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
database: conn.database,
|
||||
user: conn.user,
|
||||
password: ''
|
||||
};
|
||||
modalTitle.value = t('dbConnections.edit');
|
||||
}
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen.value = false;
|
||||
}
|
||||
|
||||
async function submitConnection() {
|
||||
if (modalMode.value === 'create' && !form.value.password) {
|
||||
showNotification('dbConnections.passwordRequired', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
name: form.value.name,
|
||||
type: form.value.type,
|
||||
host: form.value.host,
|
||||
port: form.value.port,
|
||||
database: form.value.database,
|
||||
user: form.value.user,
|
||||
};
|
||||
if (form.value.password) {
|
||||
payload.password = form.value.password;
|
||||
}
|
||||
|
||||
if (modalMode.value === 'create') {
|
||||
const res = await fetch('/api/admin/database-connections', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('dbConnections.createSuccess', 'success');
|
||||
} else {
|
||||
const res = await fetch(`/api/admin/database-connections/${form.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('dbConnections.updateSuccess', 'success');
|
||||
}
|
||||
await loadConnections();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
showNotification(modalMode.value === 'create' ? 'dbConnections.createError' : 'dbConnections.updateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
deleteConfirm.value = { show: true, id };
|
||||
}
|
||||
|
||||
async function deleteConnection(id: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/database-connections/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('dbConnections.deleteSuccess', 'success');
|
||||
await loadConnections();
|
||||
} catch (e) {
|
||||
showNotification('dbConnections.deleteError', 'error');
|
||||
} finally {
|
||||
deleteConfirm.value.show = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadConnections);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -2,68 +2,71 @@
|
||||
<AppLayout>
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="card animate-fade-in">
|
||||
<div class="card hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600">Total Users</p>
|
||||
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.totalUsers') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalUsers }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<div class="w-12 h-12 bg-primary-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center text-sm">
|
||||
<span class="text-green-600 font-medium">↑ 12%</span>
|
||||
<span class="text-gray-500 ml-2">from last month</span>
|
||||
<span class="text-green-600 font-medium">↑ {{ userGrowth }}%</span>
|
||||
<span class="text-gray-500 ml-2">{{ t('dashboard.vsLastMonth') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card animate-fade-in" style="animation-delay: 0.1s">
|
||||
<div class="card hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600">Active Sessions</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.activeSessions }}</p>
|
||||
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.totalRestaurants') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalRestaurants }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center text-sm">
|
||||
<span class="text-green-600 font-medium">↑ 5%</span>
|
||||
<span class="text-gray-500 ml-2">from last hour</span>
|
||||
<span class="text-green-600 font-medium">↑ {{ sessionGrowth }}%</span>
|
||||
<span class="text-gray-500 ml-2">{{ t('dashboard.fromLastHour') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card animate-fade-in" style="animation-delay: 0.2s">
|
||||
<div class="card hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600">System Health</p>
|
||||
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.systemHealth') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.systemHealth }}%</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="h-2 bg-gray-200 rounded-full">
|
||||
<div class="h-2 bg-blue-600 rounded-full" :style="{ width: `${stats.systemHealth}%` }"></div>
|
||||
<div class="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-blue-600 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${stats.systemHealth}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card animate-fade-in" style="animation-delay: 0.3s">
|
||||
<div class="card hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600">Uptime</p>
|
||||
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.uptime') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.uptime }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -72,16 +75,58 @@
|
||||
<div class="mt-4 flex items-center text-sm">
|
||||
<div class="flex items-center text-green-600">
|
||||
<div class="w-2 h-2 bg-green-600 rounded-full mr-2"></div>
|
||||
Operational
|
||||
{{ t('dashboard.operational') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<!-- Charts & Activity -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- User Activity Chart -->
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ t('dashboard.userActivity') }}</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="activityPeriod = 'week'" class="text-xs px-2 py-1 rounded" :class="activityPeriod === 'week' ? 'bg-primary-100 text-primary-700' : 'text-gray-500'">{{ t('dashboard.week') }}</button>
|
||||
<button @click="activityPeriod = 'month'" class="text-xs px-2 py-1 rounded" :class="activityPeriod === 'month' ? 'bg-primary-100 text-primary-700' : 'text-gray-500'">{{ t('dashboard.month') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end space-x-2 h-48">
|
||||
<div v-for="(value, index) in activityData" :key="index" class="flex-1 flex flex-col items-center">
|
||||
<div class="w-full bg-primary-100 rounded-t-lg transition-all duration-500" :style="{ height: `${value}%`, minHeight: '4px' }"></div>
|
||||
<span class="text-xs text-gray-500 mt-2">{{ activityLabels[index] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Services Status -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">{{ t('dashboard.systemServices') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="service in systemServices" :key="service.name" class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div :class="['w-2 h-2 rounded-full', service.status === 'up' ? 'bg-green-500' : 'bg-red-500']"></div>
|
||||
<span class="text-gray-700 font-medium">{{ service.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm text-gray-500">{{ service.latency }}ms</span>
|
||||
<span class="text-xs px-2 py-1 rounded-full" :class="service.status === 'up' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'">
|
||||
{{ service.status === 'up' ? t('dashboard.operational') : t('dashboard.down') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Users & Restaurants -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Recent Users</h3>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ t('dashboard.recentUsers') }}</h3>
|
||||
<router-link to="/users" class="text-sm text-primary-600 hover:text-primary-700">{{ t('dashboard.viewAll') }} →</router-link>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="user in recentUsers" :key="user.id" class="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -93,21 +138,35 @@
|
||||
<p class="text-sm text-gray-500">{{ formatDate(user.created) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">New</span>
|
||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">{{ t('dashboard.new') }}</span>
|
||||
</div>
|
||||
<div v-if="recentUsers.length === 0" class="text-center text-gray-500 py-8">{{ t('dashboard.noUsers') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">System Status</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="service in systemServices" :key="service.name" class="flex items-center justify-between">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ t('dashboard.recentRestaurants') }}</h3>
|
||||
<router-link to="/restaurants" class="text-sm text-primary-600 hover:text-primary-700">{{ t('dashboard.viewAll') }} →</router-link>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="rest in recentRestaurants" :key="rest.id" class="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div :class="['w-2 h-2 rounded-full', service.status === 'up' ? 'bg-green-500' : 'bg-red-500']"></div>
|
||||
<span class="text-gray-700">{{ service.name }}</span>
|
||||
<div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ rest.name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ rest.host }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-500">{{ formatDate(rest.created) }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">{{ service.latency }}ms</span>
|
||||
</div>
|
||||
<div v-if="recentRestaurants.length === 0" class="text-center text-gray-500 py-8">{{ t('dashboard.noRestaurants') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,88 +174,76 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import AppLayout from '../components/Layout/AppLayout.vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
|
||||
const stats = ref({ totalUsers: 0, activeSessions: 0, systemHealth: 100, uptime: '99.9%' })
|
||||
const { showNotification } = useNotification()
|
||||
const stats = ref({ totalUsers: 0, totalRestaurants: 0, systemHealth: 100, uptime: '99.9%' });
|
||||
const userGrowth = ref(12);
|
||||
const sessionGrowth = ref(5);
|
||||
const recentUsers = ref([]);
|
||||
const recentRestaurants = ref([]);
|
||||
const systemServices = ref([]);
|
||||
const activityPeriod = ref('week');
|
||||
const activityData = ref([65, 78, 82, 71, 88, 94, 72]);
|
||||
const activityLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
async function loadStats() {
|
||||
let interval: number;
|
||||
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const [usersRes, sessionsRes, healthRes] = await Promise.all([
|
||||
const [usersRes, healthRes, restaurantsRes] = await Promise.all([
|
||||
fetch('/api/admin/users'),
|
||||
fetch('/api/admin/active-sessions'),
|
||||
fetch('/api/health')
|
||||
])
|
||||
const users = await usersRes.json()
|
||||
const sessions = await sessionsRes.json()
|
||||
const health = await healthRes.json()
|
||||
fetch('/api/health'),
|
||||
fetch('/api/admin/restaurants')
|
||||
]);
|
||||
|
||||
stats.value.totalUsers = users.length
|
||||
stats.value.activeSessions = sessions.count || 0
|
||||
const users = await usersRes.json();
|
||||
const health = await healthRes.json();
|
||||
const restaurants = await restaurantsRes.json();
|
||||
|
||||
const upCount = health.checks?.filter(c => c.status === 'UP').length || 0
|
||||
const total = health.checks?.length || 1
|
||||
stats.value.systemHealth = Math.round((upCount / total) * 100)
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
stats.value.totalUsers = users.length;
|
||||
stats.value.totalRestaurants = restaurants.length;
|
||||
recentUsers.value = users.slice(-5).reverse();
|
||||
recentRestaurants.value = restaurants.slice(-5).reverse();
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
const interval = setInterval(loadStats, 5000)
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
})
|
||||
const upCount = health.checks?.filter((c: any) => c.status === 'UP').length || 0;
|
||||
const total = health.checks?.length || 1;
|
||||
stats.value.systemHealth = Math.round((upCount / total) * 100);
|
||||
|
||||
const recentUsers = ref([])
|
||||
const systemServices = ref([])
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const res = await fetch('/api/health')
|
||||
const data = await res.json()
|
||||
if (data.checks) {
|
||||
systemServices.value = data.checks.map(check => ({
|
||||
if (health.checks) {
|
||||
systemServices.value = health.checks.map((check: any) => ({
|
||||
name: check.data?.name || check.id,
|
||||
status: check.status.toLowerCase(),
|
||||
latency: check.data?.latency_ms || 0
|
||||
}))
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Health check failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
let interval: number
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
await loadHealth()
|
||||
interval = window.setInterval(loadHealth, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) clearInterval(interval)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users')
|
||||
const users = await res.json()
|
||||
stats.value.totalUsers = users.length
|
||||
recentUsers.value = users.slice(-5).reverse()
|
||||
} catch (e) {
|
||||
console.error('Failed to load data', e)
|
||||
showNotification('dashboard.loadError', 'error');
|
||||
console.error('Failed to load dashboard data', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return t('dashboard.today');
|
||||
if (diffDays === 1) return t('dashboard.yesterday');
|
||||
if (diffDays < 7) return `${diffDays} ${t('dashboard.daysAgo')}`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboardData();
|
||||
interval = window.setInterval(loadDashboardData, 10000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -9,20 +9,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-6xl font-bold text-gray-900 mb-4">404</h1>
|
||||
<p class="text-xl text-gray-600 mb-8">Oops! Page not found</p>
|
||||
<p class="text-gray-500 mb-8">The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.</p>
|
||||
<p class="text-xl text-gray-600 mb-8">{{ t('notFound.title') }}</p>
|
||||
<p class="text-gray-500 mb-8">{{ t('notFound.message') }}</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
class="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Go to Dashboard
|
||||
{{ t('notFound.goToDashboard') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/login"
|
||||
class="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
{{ t('notFound.signIn') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,5 +30,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No additional logic needed
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
641
frontend/src/views/OlapColumnsView.vue
Normal file
641
frontend/src/views/OlapColumnsView.vue
Normal file
@@ -0,0 +1,641 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex justify-between items-center mb-6 flex-wrap gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('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>
|
||||
144
frontend/src/views/Profile.vue
Normal file
144
frontend/src/views/Profile.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="card">
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<div class="relative">
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white text-2xl font-bold">
|
||||
{{ userInitials }}
|
||||
</div>
|
||||
<!-- <button class="absolute bottom-0 right-0 p-1 bg-white rounded-full shadow-md hover:shadow-lg transition-shadow">
|
||||
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button> -->
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('profile.title') }}</h1>
|
||||
<p class="text-gray-500">{{ t('profile.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveProfile" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.username') }}</label>
|
||||
<input v-model="userStore.login" type="text" disabled class="input-field bg-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.role') }}</label>
|
||||
<div class="relative">
|
||||
<input :value="userStore.role === 'admin' ? t('app.administrator') : t('app.user')" type="text" disabled class="input-field bg-gray-100" />
|
||||
<div class="absolute right-3 top-2.5">
|
||||
<span class="px-2 py-0.5 text-xs rounded-full" :class="userStore.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'">
|
||||
{{ userStore.role === 'admin' ? 'Admin' : 'User' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.email') }}</label>
|
||||
<input v-model="form.email" type="email" required class="input-field" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('profile.newPassword') }}</label>
|
||||
<input v-model="form.password" type="password" class="input-field" autocomplete="new-password" />
|
||||
<p class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('profile.confirmPassword') }}</label>
|
||||
<input v-model="form.confirmPassword" type="password" class="input-field" :class="{ 'border-red-300': passwordMismatch }" />
|
||||
<p v-if="passwordMismatch" class="text-xs text-red-600 mt-1">Passwords do not match</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('app.language') }}</label>
|
||||
<select v-model="form.language" class="input-field">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="resetForm" class="btn-secondary">{{ t('settings.reset') }}</button>
|
||||
<button type="submit" :disabled="loading" class="btn-primary flex items-center gap-2">
|
||||
<svg v-if="loading" class="animate-spin h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('settings.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import {useNotification} from "@/composables/useNotification";
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const userStore = useUserStore();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
language: 'en'
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const userInitials = computed(() => (userStore.login[0] || 'U').toUpperCase());
|
||||
const passwordMismatch = computed(() => !!form.password && form.password !== form.confirmPassword);
|
||||
|
||||
function resetForm() {
|
||||
form.email = userStore.email;
|
||||
form.password = '';
|
||||
form.confirmPassword = '';
|
||||
form.language = userStore.language;
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (form.password && form.password !== form.confirmPassword) {
|
||||
showNotification('profile.passwordsMismatch', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const updates: any = {
|
||||
email: form.email,
|
||||
language: form.language
|
||||
};
|
||||
if (form.password) updates.password = form.password;
|
||||
|
||||
const ok = await userStore.updateProfile(updates);
|
||||
loading.value = false;
|
||||
|
||||
if (ok) {
|
||||
locale.value = form.language;
|
||||
showNotification('profile.updateSuccess', 'success');
|
||||
resetForm();
|
||||
} else {
|
||||
showNotification('profile.updateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
form.email = userStore.email;
|
||||
form.language = userStore.language;
|
||||
});
|
||||
</script>
|
||||
@@ -1,83 +1,196 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Restaurants</h1>
|
||||
<button @click="openModal('create')" class="btn-primary">+ Add Restaurant</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('restaurants.pageName') }}</h1>
|
||||
<button @click="openModal('create')" class="btn-primary flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('restaurants.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Login</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Host</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="rest in restaurants" :key="rest.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.login }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
|
||||
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800">Edit</button>
|
||||
<button @click="deleteRestaurant(rest.id)" class="text-red-600 hover:text-red-800">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="modalOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2>
|
||||
<form @submit.prevent="submitRestaurant">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input v-model="form.name" type="text" required class="input-field mt-1" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Login</label>
|
||||
<input v-model="form.login" type="text" required class="input-field mt-1" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input v-model="form.password" :required="modalMode === 'create'" type="password" class="input-field mt-1" />
|
||||
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Host</label>
|
||||
<input v-model="form.host" type="text" required class="input-field mt-1" />
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('restaurants.host') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('restaurants.https') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.login') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr v-for="rest in restaurants" :key="rest.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ rest.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" :checked="rest.https" @change="toggleHttps(rest)" class="sr-only peer" />
|
||||
<div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div>
|
||||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||||
</label>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.login }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-3">
|
||||
<button @click="checkRestaurant(rest)" :disabled="rest.checking" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50">
|
||||
<svg v-if="!rest.checking" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(rest.id)" class="text-red-600 hover:text-red-800 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span v-if="rest.checkResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
|
||||
{{ rest.checkResult }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="restaurants.length === 0">
|
||||
<td colspan="7" class="px-6 py-12 text-center text-gray-500">{{ t('restaurants.noRestaurants') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модалка создания/редактирования (без изменений) -->
|
||||
<Transition name="fade">
|
||||
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
|
||||
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitRestaurant" class="p-6 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }} *</label>
|
||||
<input v-model="form.name" type="text" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('restaurants.host') }} *</label>
|
||||
<input v-model="form.host" type="text" required class="input-field" placeholder="e.g., api.example.com" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" v-model="form.https" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||
<label class="text-sm font-medium text-gray-700">{{ t('restaurants.useHttps') }}</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.login') }} *</label>
|
||||
<input v-model="form.login" type="text" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.password') }}</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
:required="modalMode === 'create'"
|
||||
type="password"
|
||||
class="input-field"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button type="button" @click="closeModal" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Модалка подтверждения удаления -->
|
||||
<Transition name="fade">
|
||||
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||
<div class="p-6 text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('restaurants.delete') }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">{{ t('restaurants.deleteConfirmation') }}</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button @click="deleteRestaurant(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import AppLayout from '../components/Layout/AppLayout.vue';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useNotification } from '@/composables/useNotification';
|
||||
|
||||
const restaurants = ref([]);
|
||||
const { t } = useI18n();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
type Restaurant = {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
https: boolean;
|
||||
login: string;
|
||||
created: string;
|
||||
checking?: boolean;
|
||||
checkResult?: string | null;
|
||||
};
|
||||
|
||||
const restaurants = ref<Restaurant[]>([]);
|
||||
const modalOpen = ref(false);
|
||||
const modalMode = ref<'create' | 'edit'>('create');
|
||||
const form = ref({ id: null, name: '', login: '', password: '', host: '' });
|
||||
const form = ref({ id: null, name: '', login: '', password: '', host: '', https: false });
|
||||
const modalTitle = ref('');
|
||||
const deleteConfirm = ref({ show: false, id: null });
|
||||
|
||||
async function loadRestaurants() {
|
||||
const res = await fetch('/api/admin/restaurants');
|
||||
restaurants.value = await res.json();
|
||||
try {
|
||||
const res = await fetch('/api/admin/restaurants');
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
restaurants.value = data.map((r: any) => ({
|
||||
...r,
|
||||
checking: false,
|
||||
checkResult: null
|
||||
}));
|
||||
} catch (e) {
|
||||
showNotification('restaurants.loadError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
@@ -85,14 +198,67 @@ function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
function openModal(mode: 'create' | 'edit', rest: any = null) {
|
||||
async function checkRestaurant(rest: Restaurant) {
|
||||
rest.checking = true;
|
||||
rest.checkResult = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/restaurants/${rest.id}/check`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
rest.checkResult = `${data.latency_ms} ms`;
|
||||
} else {
|
||||
const errorText = data.error || 'Unknown error';
|
||||
showNotification('restaurants.checkError', 'error', { error: errorText });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showNotification('restaurants.checkNetworkError', 'error', { error: error.message });
|
||||
} finally {
|
||||
rest.checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHttps(rest: Restaurant) {
|
||||
const newHttps = !rest.https;
|
||||
const payload = {
|
||||
name: rest.name,
|
||||
host: rest.host,
|
||||
login: rest.login,
|
||||
https: newHttps
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`/api/admin/restaurants/${rest.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
rest.https = newHttps;
|
||||
showNotification('restaurants.httpsUpdateSuccess', 'success');
|
||||
} else {
|
||||
showNotification('restaurants.httpsUpdateError', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('restaurants.httpsUpdateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(mode: 'create' | 'edit', rest: Restaurant | null = null) {
|
||||
modalMode.value = mode;
|
||||
if (mode === 'create') {
|
||||
form.value = { id: null, name: '', login: '', password: '', host: '' };
|
||||
modalTitle.value = 'Create Restaurant';
|
||||
} else {
|
||||
form.value = { id: rest.id, name: rest.name, login: rest.login, password: '', host: rest.host };
|
||||
modalTitle.value = 'Edit Restaurant';
|
||||
form.value = { id: null, name: '', login: '', password: '', host: '', https: false };
|
||||
modalTitle.value = t('restaurants.add');
|
||||
} else if (rest) {
|
||||
form.value = {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
login: rest.login,
|
||||
password: '',
|
||||
host: rest.host,
|
||||
https: rest.https || false
|
||||
};
|
||||
modalTitle.value = t('restaurants.edit');
|
||||
}
|
||||
modalOpen.value = true;
|
||||
}
|
||||
@@ -102,39 +268,76 @@ function closeModal() {
|
||||
}
|
||||
|
||||
async function submitRestaurant() {
|
||||
if (modalMode.value === 'create' && !form.value.password) {
|
||||
showNotification('restaurants.passwordRequired', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
login: form.value.login,
|
||||
host: form.value.host,
|
||||
https: form.value.https,
|
||||
login: form.value.login,
|
||||
...(form.value.password ? { password: form.value.password } : {})
|
||||
};
|
||||
if (modalMode.value === 'create') {
|
||||
await fetch('/api/admin/restaurants', {
|
||||
const res = await fetch('/api/admin/restaurants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('restaurants.createSuccess', 'success');
|
||||
} else {
|
||||
await fetch(`/api/admin/restaurants/${form.value.id}`, {
|
||||
const res = await fetch(`/api/admin/restaurants/${form.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('restaurants.updateSuccess', 'success');
|
||||
}
|
||||
await loadRestaurants();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
alert('Operation failed');
|
||||
showNotification(modalMode.value === 'create' ? 'restaurants.createError' : 'restaurants.updateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
deleteConfirm.value = { show: true, id };
|
||||
}
|
||||
|
||||
async function deleteRestaurant(id: number) {
|
||||
if (confirm('Are you sure?')) {
|
||||
await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' });
|
||||
try {
|
||||
const res = await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('restaurants.deleteSuccess', 'success');
|
||||
await loadRestaurants();
|
||||
} catch (e) {
|
||||
showNotification('restaurants.deleteError', 'error');
|
||||
} finally {
|
||||
deleteConfirm.value.show = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRestaurants);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,110 +1,188 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Users Management</h1>
|
||||
<button @click="openModal('create')" class="btn-primary">+ Add User</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('users.pageName') }}</h1>
|
||||
<button @click="openModal('create')" class="btn-primary flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('users.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Login</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Active</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">IP</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.login }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.email }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button
|
||||
v-if="user.id !== currentUserId"
|
||||
@click="toggleActive(user)"
|
||||
:class="user.active ? 'text-green-600' : 'text-red-600'"
|
||||
>
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
</button>
|
||||
<span v-else class="text-gray-400 text-sm">(You)</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.ip || '-' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ formatDate(user.created) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
|
||||
<button @click="openModal('edit', user)" class="text-blue-600">Edit</button>
|
||||
<button
|
||||
v-if="user.id !== currentUserId"
|
||||
@click="deleteUser(user.id)"
|
||||
class="text-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="modalOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2>
|
||||
<form @submit.prevent="submitUser">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input v-model="form.email" type="text" required class="input-field mt-1" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Login</label>
|
||||
<input v-model="form.login" type="text" required class="input-field mt-1" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input v-model="form.password" :required="modalMode === 'create'" type="password" class="input-field mt-1" />
|
||||
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.login') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.email') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.role') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.status') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.ip') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr v-for="user in users" :key="user.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ user.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ user.login }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ user.email }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span class="px-2 py-1 text-xs rounded-full" :class="user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'">
|
||||
{{ user.role === 'admin' ? t('app.administrator') : t('app.user') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div v-if="user.id === userStore.id" class="text-xs text-gray-500">{{ t('users.you') }}</div>
|
||||
<label v-else class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="user.active"
|
||||
@change="toggleActive(user)"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div>
|
||||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||||
</label>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ user.ip || '-' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(user.created) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||
<button @click="openModal('edit', user)" class="text-blue-600 hover:text-blue-800 transition-colors">
|
||||
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="user.id !== userStore.id"
|
||||
@click="confirmDelete(user.id)"
|
||||
class="text-red-600 hover:text-red-800 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="users.length === 0">
|
||||
<td colspan="8" class="px-6 py-12 text-center text-gray-500">{{ t('users.noUsers') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for create/edit user -->
|
||||
<Transition name="fade">
|
||||
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
|
||||
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitUser" class="p-6 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.email') }} *</label>
|
||||
<input v-model="form.email" type="email" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.login') }} *</label>
|
||||
<input v-model="form.login" type="text" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.role') }}</label>
|
||||
<select v-model="form.role" class="input-field" :disabled="isEditingSelf">
|
||||
<option value="user">{{ t('app.user') }}</option>
|
||||
<option value="admin">{{ t('app.administrator') }}</option>
|
||||
</select>
|
||||
<p v-if="isEditingSelf" class="text-xs text-amber-600 mt-1">{{ t('users.cannotChangeOwnRole') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.password') }}</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
:required="modalMode === 'create'"
|
||||
type="password"
|
||||
class="input-field"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button type="button" @click="closeModal" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||
<div class="p-6 text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('users.delete') }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">{{ t('users.deleteConfirmation') }}</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button @click="deleteUser(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import AppLayout from '../components/Layout/AppLayout.vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useNotification } from '@/composables/useNotification';
|
||||
|
||||
const currentUserId = ref<number | null>(null);
|
||||
const { t } = useI18n();
|
||||
const { showNotification } = useNotification();
|
||||
const userStore = useUserStore();
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/me');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
currentUserId.value = data.id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load current user', e);
|
||||
}
|
||||
}
|
||||
|
||||
const users = ref([]);
|
||||
const users = ref<any[]>([]);
|
||||
const modalOpen = ref(false);
|
||||
const modalMode = ref<'create' | 'edit'>('create');
|
||||
const form = ref({ id: null, login: '', email: '', password: '' });
|
||||
const form = ref({ id: null, login: '', email: '', password: '', role: 'user' });
|
||||
const modalTitle = ref('');
|
||||
const deleteConfirm = ref({ show: false, id: null });
|
||||
|
||||
const isEditingSelf = computed(() => {
|
||||
return modalMode.value === 'edit' && form.value.id === userStore.id;
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const res = await fetch('/api/admin/users');
|
||||
users.value = await res.json();
|
||||
try {
|
||||
const res = await fetch('/api/admin/users');
|
||||
if (!res.ok) throw new Error();
|
||||
users.value = await res.json();
|
||||
} catch (e) {
|
||||
showNotification('users.loadError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
@@ -113,18 +191,24 @@ function formatDate(dateStr: string) {
|
||||
}
|
||||
|
||||
async function toggleActive(user: any) {
|
||||
await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' })
|
||||
await loadUsers()
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' });
|
||||
if (!res.ok) throw new Error();
|
||||
await loadUsers();
|
||||
showNotification('users.statusUpdated', 'success');
|
||||
} catch (e) {
|
||||
showNotification('users.statusUpdateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(mode: 'create' | 'edit', user: any = null) {
|
||||
modalMode.value = mode;
|
||||
if (mode === 'create') {
|
||||
form.value = { id: null, login: '', email: '', password: '' };
|
||||
modalTitle.value = 'Create User';
|
||||
form.value = { id: null, login: '', email: '', password: '', role: 'user' };
|
||||
modalTitle.value = t('users.add');
|
||||
} else {
|
||||
form.value = { id: user.id, login: user.login, email: user.email, password: '' }; // добавлен email
|
||||
modalTitle.value = 'Edit User';
|
||||
form.value = { id: user.id, login: user.login, email: user.email, password: '', role: user.role || 'user' };
|
||||
modalTitle.value = t('users.edit');
|
||||
}
|
||||
modalOpen.value = true;
|
||||
}
|
||||
@@ -134,49 +218,81 @@ function closeModal() {
|
||||
}
|
||||
|
||||
async function submitUser() {
|
||||
if (isEditingSelf.value && form.value.role !== userStore.role) {
|
||||
showNotification('users.cannotChangeOwnRole', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalMode.value === 'create' && !form.value.password) {
|
||||
showNotification('users.passwordRequired', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
login: form.value.login,
|
||||
email: form.value.email,
|
||||
role: form.value.role,
|
||||
};
|
||||
if (form.value.password) {
|
||||
payload.password = form.value.password;
|
||||
}
|
||||
|
||||
let response;
|
||||
if (modalMode.value === 'create') {
|
||||
if (!form.value.password) {
|
||||
alert('Password is required');
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/api/admin/users', {
|
||||
response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Create failed');
|
||||
if (!response.ok) throw new Error();
|
||||
showNotification('users.createSuccess', 'success');
|
||||
} else {
|
||||
const res = await fetch(`/api/admin/users/${form.value.id}`, {
|
||||
response = await fetch(`/api/admin/users/${form.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Update failed');
|
||||
if (!response.ok) throw new Error();
|
||||
showNotification('users.updateSuccess', 'success');
|
||||
}
|
||||
|
||||
await loadUsers();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
alert('Operation failed: ' + e.message);
|
||||
showNotification(modalMode.value === 'create' ? 'users.createError' : 'users.updateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
deleteConfirm.value = { show: true, id };
|
||||
}
|
||||
|
||||
async function deleteUser(id: number) {
|
||||
if (confirm('Are you sure?')) {
|
||||
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('users.deleteSuccess', 'success');
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
showNotification('users.deleteError', 'error');
|
||||
} finally {
|
||||
deleteConfirm.value.show = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCurrentUser();
|
||||
await loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Welcome Back</h1>
|
||||
<p class="text-gray-600 mt-2">Sign in to your account</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ t('login.title') }}</h1>
|
||||
<p class="text-gray-600 mt-2">{{ t('login.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Username or Email</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('login.username') }}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.password') }}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -65,10 +65,14 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
<span class="ml-2 text-sm text-gray-600">Remember me</span>
|
||||
<span class="ml-2 text-sm text-gray-600">{{ t('login.remember') }}</span>
|
||||
</label>
|
||||
<router-link to="/register" class="text-sm text-primary-600 hover:text-primary-700">
|
||||
Create account
|
||||
<router-link
|
||||
v-if="settings.enableRegistration"
|
||||
to="/register"
|
||||
class="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{{ t('login.createAccount') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +81,7 @@
|
||||
:disabled="loading"
|
||||
class="w-full btn-primary py-3 relative"
|
||||
>
|
||||
<span v-if="!loading">Sign In</span>
|
||||
<span v-if="!loading">{{ t('login.signin') }}</span>
|
||||
<svg v-else class="animate-spin h-5 w-5 mx-auto text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -99,8 +103,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
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 { t, locale } = useI18n()
|
||||
const form = ref({ login: '', password: '' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
@@ -109,27 +119,26 @@ const showPassword = ref(false)
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
await userStore.fetchProfile()
|
||||
// Устанавливаем язык интерфейса из профиля
|
||||
if (userStore.language) {
|
||||
locale.value = userStore.language
|
||||
localStorage.setItem('locale', userStore.language)
|
||||
}
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
// Пытаемся получить текст ошибки от сервера
|
||||
const text = await res.text()
|
||||
if (text && text.trim()) {
|
||||
error.value = text
|
||||
} else {
|
||||
error.value = 'Invalid username or password'
|
||||
}
|
||||
error.value = text || t('login.invalidCredentials')
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Network error. Please try again.'
|
||||
error.value = t('login.networkError')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Create Account</h1>
|
||||
<p class="text-gray-600 mt-2">Register and wait for admin approval</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ t('register.title') }}</h1>
|
||||
<p class="text-gray-600 mt-2">{{ t('register.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<form @submit.prevent="handleRegister" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('register.username') }}</label>
|
||||
<input v-model="form.login" type="text" required minlength="3" class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.email') }}</label>
|
||||
<input v-model="form.email" type="email" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.password') }}</label>
|
||||
<input v-model="form.password" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="w-full btn-primary py-3">
|
||||
<span v-if="!loading">Register</span>
|
||||
<span v-if="!loading">{{ t('register.register') }}</span>
|
||||
<span v-else>Loading...</span>
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="success" class="mt-4 text-green-600 text-center">Account created! Wait for admin activation.</p>
|
||||
<p v-if="success" class="mt-4 text-green-600 text-center">{{ t('register.success') }}</p>
|
||||
<p v-if="error" class="mt-4 text-red-600 text-center">{{ error }}</p>
|
||||
<p class="mt-4 text-center text-sm text-gray-600">
|
||||
Already have an account? <router-link to="/login" class="text-primary-600">Login</router-link>
|
||||
{{ t('register.alreadyHaveAccount') }} <router-link to="/login" class="text-primary-600">{{ t('login.signin') }}</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,6 +41,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
const form = ref({ login: '', email: '', password: '' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Setup Admin Account</h1>
|
||||
<p class="text-gray-600 mt-2">Create your administrator account</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ t('setup.title') }}</h1>
|
||||
<p class="text-gray-600 mt-2">{{ t('setup.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Setup Form -->
|
||||
@@ -19,19 +19,19 @@
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center text-white font-semibold">1</div>
|
||||
<div class="ml-2 text-sm font-medium text-gray-900">Account Details</div>
|
||||
<div class="ml-2 text-sm font-medium text-gray-900">{{ t('setup.step1') }}</div>
|
||||
</div>
|
||||
<div class="mx-4 w-12 h-0.5 bg-gray-300"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center text-gray-600 font-semibold">2</div>
|
||||
<div class="ml-2 text-sm font-medium text-gray-500">Complete</div>
|
||||
<div class="ml-2 text-sm font-medium text-gray-500">{{ t('setup.step2') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSetup" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.username') }}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.email') }}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -72,7 +72,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.password') }}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -105,7 +105,7 @@
|
||||
<!-- Password Strength -->
|
||||
<div v-if="form.password" class="mt-2">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-600">Password strength</span>
|
||||
<span class="text-xs text-gray-600">{{ t('setup.passwordStrength') }}</span>
|
||||
<span class="text-xs font-medium" :class="strengthColor">{{ strengthText }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
@@ -123,7 +123,7 @@
|
||||
:disabled="loading"
|
||||
class="w-full btn-primary py-3 relative overflow-hidden group"
|
||||
>
|
||||
<span v-if="!loading" class="relative z-10">Create Account</span>
|
||||
<span v-if="!loading" class="relative z-10">{{ t('setup.createAccount') }}</span>
|
||||
<svg v-else class="animate-spin h-5 w-5 mx-auto text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -151,7 +151,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const form = ref({ login: '', email: '', password: '' });
|
||||
const loading = ref(false)
|
||||
@@ -186,8 +187,9 @@ const passwordStrength = computed(() => {
|
||||
const strengthPercent = computed(() => (passwordStrength.value / 5) * 100)
|
||||
|
||||
const strengthText = computed(() => {
|
||||
const texts = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong']
|
||||
return texts[passwordStrength.value - 1] || ''
|
||||
const keys = ['setup.veryWeak', 'setup.weak', 'setup.fair', 'setup.good', 'setup.strong']
|
||||
const key = keys[passwordStrength.value - 1]
|
||||
return key ? t(key) : ''
|
||||
})
|
||||
|
||||
const strengthColor = computed(() => {
|
||||
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "vue",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080' // для разработки
|
||||
|
||||
91
iiko-app.dev.xserver.su.nginx.conf
Normal file
91
iiko-app.dev.xserver.su.nginx.conf
Normal 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;
|
||||
}
|
||||
BIN
libs/RoaringBitmap-1.0.6.jar
Normal file
BIN
libs/RoaringBitmap-1.0.6.jar
Normal file
Binary file not shown.
BIN
libs/antlr4-runtime-4.13.2.jar
Normal file
BIN
libs/antlr4-runtime-4.13.2.jar
Normal file
Binary file not shown.
BIN
libs/asm-9.7.jar
Normal file
BIN
libs/asm-9.7.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-client-0.9.8.jar
Normal file
BIN
libs/clickhouse-client-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-data-0.9.8.jar
Normal file
BIN
libs/clickhouse-data-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-http-client-0.9.8.jar
Normal file
BIN
libs/clickhouse-http-client-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-jdbc-0.9.8.jar
Normal file
BIN
libs/clickhouse-jdbc-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/client-v2-0.9.8.jar
Normal file
BIN
libs/client-v2-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/commons-codec-1.19.0.jar
Normal file
BIN
libs/commons-codec-1.19.0.jar
Normal file
Binary file not shown.
BIN
libs/commons-compress-1.28.0.jar
Normal file
BIN
libs/commons-compress-1.28.0.jar
Normal file
Binary file not shown.
BIN
libs/commons-io-2.20.0.jar
Normal file
BIN
libs/commons-io-2.20.0.jar
Normal file
Binary file not shown.
BIN
libs/commons-lang3-3.20.0.jar
Normal file
BIN
libs/commons-lang3-3.20.0.jar
Normal file
Binary file not shown.
BIN
libs/error_prone_annotations-2.36.0.jar
Normal file
BIN
libs/error_prone_annotations-2.36.0.jar
Normal file
Binary file not shown.
BIN
libs/failureaccess-1.0.3.jar
Normal file
BIN
libs/failureaccess-1.0.3.jar
Normal file
Binary file not shown.
BIN
libs/guava-33.4.6-jre.jar
Normal file
BIN
libs/guava-33.4.6-jre.jar
Normal file
Binary file not shown.
BIN
libs/httpclient5-5.4.4.jar
Normal file
BIN
libs/httpclient5-5.4.4.jar
Normal file
Binary file not shown.
BIN
libs/httpcore5-5.3.4.jar
Normal file
BIN
libs/httpcore5-5.3.4.jar
Normal file
Binary file not shown.
BIN
libs/httpcore5-h2-5.3.4.jar
Normal file
BIN
libs/httpcore5-h2-5.3.4.jar
Normal file
Binary file not shown.
BIN
libs/j2objc-annotations-3.0.0.jar
Normal file
BIN
libs/j2objc-annotations-3.0.0.jar
Normal file
Binary file not shown.
BIN
libs/jdbc-v2-0.9.8.jar
Normal file
BIN
libs/jdbc-v2-0.9.8.jar
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
libs/lz4-java-1.10.4.jar
Normal file
BIN
libs/lz4-java-1.10.4.jar
Normal file
Binary file not shown.
BIN
libs/mysql-connector-j-9.7.0.jar
Normal file
BIN
libs/mysql-connector-j-9.7.0.jar
Normal file
Binary file not shown.
BIN
libs/postgresql-42.7.11.jar
Normal file
BIN
libs/postgresql-42.7.11.jar
Normal file
Binary file not shown.
BIN
libs/protobuf-java-4.31.1.jar
Normal file
BIN
libs/protobuf-java-4.31.1.jar
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
libs/vertx-core-logging-5.0.11.jar
Normal file
BIN
libs/vertx-core-logging-5.0.11.jar
Normal file
Binary file not shown.
Binary file not shown.
BIN
libs/vertx-jdbc-client-5.0.11.jar
Normal file
BIN
libs/vertx-jdbc-client-5.0.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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
libs/vertx-web-sstore-redis-5.0.11.jar
Normal file
BIN
libs/vertx-web-sstore-redis-5.0.11.jar
Normal file
Binary file not shown.
@@ -1,50 +0,0 @@
|
||||
package su.xserver.iikocon;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
public class DateRangeSetup {
|
||||
public static void main(String[] args) {
|
||||
// Параметры по умолчанию
|
||||
String login = "4444";
|
||||
String password = "4444";
|
||||
String server = "folk-amber-co.iiko.it";
|
||||
String presetId = "7ddc40c3-9d5f-408f-aa1e-652964b36c6c";
|
||||
|
||||
// Вычисление dateFrom и dateTo
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate dateFrom = today.minusDays(7);
|
||||
LocalDate dateTo = today;
|
||||
|
||||
// Переопределение из аргументов командной строки
|
||||
if (args.length > 0 && args[0] != null && !args[0].isEmpty()) {
|
||||
try {
|
||||
dateFrom = LocalDate.parse(args[0]);
|
||||
} catch (DateTimeParseException e) {
|
||||
System.err.println("Ошибка парсинга dateFrom: " + args[0] + ". Используется значение по умолчанию.");
|
||||
}
|
||||
}
|
||||
|
||||
if (args.length > 1 && args[1] != null && !args[1].isEmpty()) {
|
||||
try {
|
||||
dateTo = LocalDate.parse(args[1]);
|
||||
} catch (DateTimeParseException e) {
|
||||
System.err.println("Ошибка парсинга dateTo: " + args[1] + ". Используется значение по умолчанию.");
|
||||
}
|
||||
}
|
||||
|
||||
// Форматирование дат в YYYY-MM-DD
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
String formattedDateFrom = dateFrom.format(formatter);
|
||||
String formattedDateTo = dateTo.format(formatter);
|
||||
|
||||
// Вывод переменных (можно заменить на дальнейшее использование)
|
||||
System.out.println("login=" + login);
|
||||
System.out.println("password=" + password);
|
||||
System.out.println("server=" + server);
|
||||
System.out.println("presetId=" + presetId);
|
||||
System.out.println("dateFrom=" + formattedDateFrom);
|
||||
System.out.println("dateTo=" + formattedDateTo);
|
||||
}
|
||||
}
|
||||
@@ -6,22 +6,29 @@ import io.vertx.config.ConfigStoreOptions;
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.http.HttpServer;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.Router;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import io.vertx.ext.web.client.HttpRequest;
|
||||
import io.vertx.ext.web.client.HttpResponse;
|
||||
import io.vertx.ext.web.client.WebClient;
|
||||
import io.vertx.ext.web.handler.BodyHandler;
|
||||
import io.vertx.ext.web.handler.SessionHandler;
|
||||
import io.vertx.ext.web.handler.StaticHandler;
|
||||
import io.vertx.ext.web.sstore.LocalSessionStore;
|
||||
import io.vertx.ext.web.sstore.SessionStore;
|
||||
|
||||
import io.vertx.ext.web.sstore.redis.RedisSessionStore;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import su.xserver.iikocon.config.AppConfig;
|
||||
import su.xserver.iikocon.service.DataBaseService;
|
||||
import su.xserver.iikocon.service.HealthCheckService;
|
||||
import su.xserver.iikocon.service.RedisService;
|
||||
import su.xserver.iikocon.handler.*;
|
||||
import su.xserver.iikocon.iiko.IikoHandler;
|
||||
import su.xserver.iikocon.iiko.IikoOlapClient;
|
||||
import su.xserver.iikocon.service.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -33,13 +40,18 @@ public class MainVerticle extends AbstractVerticle {
|
||||
private RedisService redis;
|
||||
private HttpServer httpServer;
|
||||
private AppConfig config;
|
||||
private SessionStore sessionStore;
|
||||
|
||||
private UserService userService;
|
||||
private RestaurantService restaurantService;
|
||||
private ExternalDataBaseService externalDataBaseService;
|
||||
private SettingsService settingsService;
|
||||
|
||||
@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()
|
||||
.setType("file")
|
||||
@@ -59,12 +71,11 @@ public class MainVerticle extends AbstractVerticle {
|
||||
db = new DataBaseService(vertx, config.database);
|
||||
redis = new RedisService(vertx, config.redis);
|
||||
|
||||
// Инициализация сервисов
|
||||
userService = new UserService(db.getPool());
|
||||
restaurantService = new RestaurantService(db.getPool());
|
||||
settingsService = new SettingsService(db.getPool());
|
||||
externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx);
|
||||
|
||||
// Инициализация БД (создание таблицы users)
|
||||
userService.initDatabase().onFailure(err -> {
|
||||
log.error("Failed to initialize database", err);
|
||||
startPromise.fail(err);
|
||||
@@ -77,31 +88,154 @@ public class MainVerticle extends AbstractVerticle {
|
||||
log.error("Failed to initialize database", err);
|
||||
startPromise.fail(err);
|
||||
});
|
||||
externalDataBaseService.initDatabase().onFailure(err -> {
|
||||
log.error("Failed to initialize database", err);
|
||||
startPromise.fail(err);
|
||||
});
|
||||
|
||||
Router router = initRouter();
|
||||
|
||||
startHttp(router, startPromise);
|
||||
createRouterAndStartHttp(startPromise);
|
||||
|
||||
})
|
||||
.onFailure(startPromise::fail);
|
||||
|
||||
}
|
||||
|
||||
private Router initRouter() {
|
||||
private void createRouterAndStartHttp(Promise<Void> startPromise) {
|
||||
settingsService.get("session_timeout_minutes")
|
||||
.compose(timeoutStr -> {
|
||||
long timeoutMinutes = 60; // default
|
||||
if (timeoutStr != null && !timeoutStr.isEmpty()) {
|
||||
try {
|
||||
timeoutMinutes = Long.parseLong(timeoutStr);
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
long timeoutMs = timeoutMinutes * 60 * 1000;
|
||||
|
||||
// Настройка сессий (используем LocalSessionStore для простоты)
|
||||
SessionStore sessionStore = LocalSessionStore.create(vertx);
|
||||
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
||||
.setSessionCookieName("admin.session")
|
||||
.setCookieHttpOnlyFlag(true)
|
||||
.setCookieSecureFlag(false)
|
||||
.setSessionTimeout(3600000);
|
||||
sessionStore = RedisSessionStore.create(vertx, redis.getRedis());
|
||||
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
||||
.setSessionCookieName("admin.session")
|
||||
.setCookieHttpOnlyFlag(true)
|
||||
.setCookieSecureFlag(false)
|
||||
.setSessionTimeout(timeoutMs);
|
||||
|
||||
Router router = initRouter(sessionHandler);
|
||||
startHttp(router, startPromise);
|
||||
return Future.succeededFuture();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
log.error("Failed to get session timeout", err);
|
||||
startPromise.fail(err);
|
||||
});
|
||||
}
|
||||
|
||||
private void setupPhpmyadminProxy(Router router) {
|
||||
if (config.pma == null || !config.pma.enabled) return;
|
||||
|
||||
String upstream = config.pma.upstream;
|
||||
String basePath = config.pma.basePath;
|
||||
|
||||
final URI upstreamUri = URI.create(upstream);
|
||||
final String host = upstreamUri.getHost();
|
||||
int portTmp = upstreamUri.getPort();
|
||||
if (portTmp == -1) {
|
||||
portTmp = "https".equals(upstreamUri.getScheme()) ? 443 : 80;
|
||||
}
|
||||
final int port = portTmp;
|
||||
|
||||
final WebClient webClient = WebClient.create(vertx);
|
||||
|
||||
router.route(basePath + "/*").handler(ctx -> {
|
||||
if (ctx.session() != null && "admin".equals(ctx.session().get("role"))) {
|
||||
ctx.next();
|
||||
} else {
|
||||
ctx.response().putHeader("Location", "/").setStatusCode(302).end();
|
||||
}
|
||||
});
|
||||
|
||||
router.route(basePath + "/*").handler(ctx -> {
|
||||
String targetPathBase = ctx.request().path().substring(basePath.length());
|
||||
if (targetPathBase.isEmpty()) targetPathBase = "/";
|
||||
String targetPath = targetPathBase;
|
||||
String query = ctx.request().query();
|
||||
if (query != null && !query.isEmpty()) {
|
||||
targetPath += "?" + query;
|
||||
}
|
||||
final String targetPathFinal = targetPath;
|
||||
|
||||
final HttpRequest<Buffer> proxyReq = webClient.request(
|
||||
ctx.request().method(), port, host, targetPathFinal
|
||||
);
|
||||
|
||||
ctx.request().headers().forEach(header -> {
|
||||
if (!"host".equalsIgnoreCase(header.getKey())) {
|
||||
proxyReq.putHeader(header.getKey(), header.getValue());
|
||||
}
|
||||
});
|
||||
proxyReq.putHeader("Host", host + ":" + port);
|
||||
|
||||
ctx.request().bodyHandler(body -> {
|
||||
if (body != null && body.length() > 0) {
|
||||
proxyReq.sendBuffer(body)
|
||||
.onSuccess(resp -> sendResponse(ctx, resp))
|
||||
.onFailure(err -> sendError(ctx, err));
|
||||
} else {
|
||||
proxyReq.send()
|
||||
.onSuccess(resp -> sendResponse(ctx, resp))
|
||||
.onFailure(err -> sendError(ctx, err));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void sendResponse(RoutingContext ctx, HttpResponse<Buffer> resp) {
|
||||
ctx.response().setStatusCode(resp.statusCode());
|
||||
resp.headers().forEach(h -> ctx.response().putHeader(h.getKey(), h.getValue()));
|
||||
ctx.response().end(resp.body());
|
||||
}
|
||||
|
||||
private void sendError(RoutingContext ctx, Throwable err) {
|
||||
log.error("Proxy error: {}", err.getMessage());
|
||||
ctx.response().setStatusCode(502).end("Bad Gateway: " + err.getMessage());
|
||||
}
|
||||
|
||||
private Router initRouter(SessionHandler sessionHandler) {
|
||||
|
||||
// Роутер
|
||||
Router router = Router.router(vertx);
|
||||
router.route().handler(BodyHandler.create());
|
||||
|
||||
router.route().handler(ctx -> {
|
||||
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);
|
||||
|
||||
setupPhpmyadminProxy(router);
|
||||
|
||||
SecurityHandler securityHandlers = new SecurityHandler(settingsService);
|
||||
|
||||
// Обработчики безопасности
|
||||
router.route().handler(securityHandlers.hostValidator());
|
||||
router.route().handler(securityHandlers.proxyHeadersHandler());
|
||||
router.route().handler(securityHandlers.cspHeader());
|
||||
|
||||
// CORS для разработки
|
||||
router.route().handler(ctx -> {
|
||||
ctx.response()
|
||||
@@ -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
|
||||
HealthCheckService healthCheckService = new HealthCheckService(vertx, redis, db);
|
||||
healthCheckService.registerHealthCheck(router);
|
||||
@@ -149,7 +290,11 @@ public class MainVerticle extends AbstractVerticle {
|
||||
|
||||
router.post("/api/logout").handler(authHandler::handleLogout);
|
||||
|
||||
router.post("/api/register").handler(rc -> {
|
||||
router.post("/api/register").handler(rc -> settingsService.get("enable_registration").onComplete(regCheck -> {
|
||||
if (regCheck.succeeded() && "false".equals(regCheck.result())) {
|
||||
rc.response().setStatusCode(403).end(new JsonObject().put("error", "Registration is disabled").encode());
|
||||
return;
|
||||
}
|
||||
JsonObject body = rc.body().asJsonObject();
|
||||
String login = body.getString("login");
|
||||
String email = body.getString("email");
|
||||
@@ -162,10 +307,30 @@ public class MainVerticle extends AbstractVerticle {
|
||||
userService.createUser(login, email, password, ip)
|
||||
.onSuccess(v -> rc.response().setStatusCode(201).end(new JsonObject().put("success", true).encode()))
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
}));
|
||||
|
||||
router.route("/api/profile").handler(authHandler::requireAuth);
|
||||
router.get("/api/profile").handler(rc -> {
|
||||
Integer userId = rc.session().get("userId");
|
||||
userService.getProfile(userId)
|
||||
.onSuccess(profile -> rc.response().putHeader("Content-Type", "application/json").end(profile.encode()))
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
router.put("/api/profile").handler(rc -> {
|
||||
Integer userId = rc.session().get("userId");
|
||||
JsonObject body = rc.body().asJsonObject();
|
||||
String email = body.getString("email");
|
||||
String password = body.getString("password");
|
||||
String language = body.getString("language");
|
||||
userService.updateProfile(userId, email, password, language)
|
||||
.onSuccess(v -> {
|
||||
if (language != null) rc.session().put("language", language);
|
||||
rc.response().end(new JsonObject().put("success", true).encode());
|
||||
})
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
router.route("/api/admin/*").handler(authHandler::requireAuth);
|
||||
|
||||
router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
rc.response()
|
||||
@@ -176,18 +341,20 @@ public class MainVerticle extends AbstractVerticle {
|
||||
}
|
||||
}));
|
||||
|
||||
router.route("/api/admin/users*").handler(AdminHandler::requireAdmin);
|
||||
router.post("/api/admin/users").handler(rc -> {
|
||||
JsonObject body = rc.body().asJsonObject();
|
||||
String login = body.getString("login");
|
||||
String email = body.getString("email");
|
||||
String password = body.getString("password");
|
||||
String role = body.getString("role");
|
||||
String ip = rc.request().remoteAddress().host();
|
||||
if (login == null || email == null || password == null) {
|
||||
rc.response().setStatusCode(400).end("Missing login, email or password");
|
||||
return;
|
||||
}
|
||||
// Создаём активного пользователя (active = true)
|
||||
userService.createUser(login, email, password, ip, true)
|
||||
if (role == null || role.isEmpty()) role = "user";
|
||||
userService.createUser(login, email, password, ip, true, role)
|
||||
.onSuccess(v -> rc.response().setStatusCode(201).end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
@@ -198,12 +365,13 @@ public class MainVerticle extends AbstractVerticle {
|
||||
String login = body.getString("login");
|
||||
String email = body.getString("email");
|
||||
String password = body.getString("password");
|
||||
String role = body.getString("role");
|
||||
String ip = rc.request().remoteAddress().host();
|
||||
if (login == null || email == null) {
|
||||
rc.response().setStatusCode(400).end("Missing login or email");
|
||||
return;
|
||||
}
|
||||
userService.updateUser(id, login, email, password, ip)
|
||||
userService.updateUser(id, login, email, password, ip, role)
|
||||
.onSuccess(v -> rc.response().end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
@@ -226,7 +394,7 @@ public class MainVerticle extends AbstractVerticle {
|
||||
|
||||
router.put("/api/admin/users/:id/activate").handler(rc -> {
|
||||
int id = Integer.parseInt(rc.pathParam("id"));
|
||||
boolean active = Boolean.parseBoolean(rc.queryParam("active").get(0));
|
||||
boolean active = Boolean.parseBoolean(rc.queryParam("active").getFirst());
|
||||
Integer currentUserId = rc.session().get("userId");
|
||||
|
||||
if (currentUserId != null && currentUserId == id) {
|
||||
@@ -239,21 +407,6 @@ public class MainVerticle extends AbstractVerticle {
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
// Получение текущего пользователя
|
||||
router.get("/api/admin/me").handler(rc -> {
|
||||
Integer userId = rc.session().get("userId");
|
||||
if (userId != null) {
|
||||
rc.response()
|
||||
.putHeader("Content-Type", "application/json")
|
||||
.end(new JsonObject()
|
||||
.put("id", userId)
|
||||
.put("login", rc.session().get("login"))
|
||||
.encode());
|
||||
} else {
|
||||
rc.response().setStatusCode(401).end();
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/admin/restaurants").handler(rc -> restaurantService.getAllRestaurants().onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
rc.response()
|
||||
@@ -274,17 +427,36 @@ public class MainVerticle extends AbstractVerticle {
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
router.get("/api/admin/restaurants/:id/check").handler(rc -> {
|
||||
int id = Integer.parseInt(rc.pathParam("id"));
|
||||
restaurantService.findById(id)
|
||||
.onSuccess(rest -> {
|
||||
if (rest == null) {
|
||||
rc.response().setStatusCode(404).end();
|
||||
} else {
|
||||
IikoOlapClient iiko = new IikoOlapClient(vertx, rest);
|
||||
|
||||
iiko.checkConnection()
|
||||
.onSuccess(res -> rc.response().putHeader("Content-Type", "application/json").end(res.encode()))
|
||||
.onFailure(err -> rc.response().putHeader("Content-Type", "application/json").end(
|
||||
new JsonObject().put("success", false).put("error", err.getMessage()).encode()));
|
||||
}
|
||||
})
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
router.post("/api/admin/restaurants").handler(rc -> {
|
||||
JsonObject body = rc.body().asJsonObject();
|
||||
String name = body.getString("name");
|
||||
String login = body.getString("login");
|
||||
String password = body.getString("password");
|
||||
String host = body.getString("host");
|
||||
boolean https = body.getBoolean("https", false);
|
||||
if (name == null || login == null || password == null || host == null) {
|
||||
rc.response().setStatusCode(400).end("Missing fields");
|
||||
return;
|
||||
}
|
||||
restaurantService.createRestaurant(name, login, password, host)
|
||||
restaurantService.createRestaurant(name, login, password, host, https)
|
||||
.onSuccess(v -> rc.response().setStatusCode(201).end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
@@ -296,11 +468,12 @@ public class MainVerticle extends AbstractVerticle {
|
||||
String login = body.getString("login");
|
||||
String password = body.getString("password");
|
||||
String host = body.getString("host");
|
||||
boolean https = body.getBoolean("https", false);
|
||||
if (name == null || login == null || host == null) {
|
||||
rc.response().setStatusCode(400).end("Missing required fields");
|
||||
return;
|
||||
}
|
||||
restaurantService.updateRestaurant(id, name, login, password, host)
|
||||
restaurantService.updateRestaurant(id, name, login, password, host, https)
|
||||
.onSuccess(v -> rc.response().end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
@@ -312,28 +485,25 @@ public class MainVerticle extends AbstractVerticle {
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
// Получение всех настроек
|
||||
router.get("/api/settings").handler(rc -> {
|
||||
settingsService.getAll()
|
||||
settingsService.getPublicSettings()
|
||||
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end());
|
||||
});
|
||||
|
||||
// Получить метаданные всех настроек (для построения формы)
|
||||
router.get("/api/settings/meta").handler(rc -> {
|
||||
router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin);
|
||||
router.get("/api/admin/settings/meta").handler(rc -> {
|
||||
settingsService.getMetadata()
|
||||
.onSuccess(meta -> rc.response().putHeader("Content-Type", "application/json").end(meta.encode()))
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
// Получить все настройки со значениями по умолчанию
|
||||
router.get("/api/settings/all").handler(rc -> {
|
||||
router.get("/api/admin/settings").handler(rc -> {
|
||||
settingsService.getAllWithDefaults()
|
||||
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
// Обновление настроек (админ)
|
||||
router.put("/api/admin/settings").handler(rc -> {
|
||||
JsonObject body = rc.body().asJsonObject();
|
||||
List<Future<Void>> futures = new ArrayList<>(); // явно указываем тип Future<Void>
|
||||
@@ -343,17 +513,14 @@ public class MainVerticle extends AbstractVerticle {
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
|
||||
// Количество активных сессий (на основе Redis)
|
||||
router.get("/api/admin/active-sessions").handler(rc -> {
|
||||
// TODO: реализовать подсчёт активных сессий через Redis или другой механизм
|
||||
rc.response().end(new JsonObject().put("count", 0).encode());
|
||||
});
|
||||
externalDataBaseService.handleRoute(router);
|
||||
|
||||
new IikoHandler(vertx, router, db, restaurantService, authHandler);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
private void startHttp(Router router, Promise<Void> startPromise) {
|
||||
// Запуск HTTP-сервера
|
||||
httpServer = vertx.createHttpServer();
|
||||
httpServer.requestHandler(router).listen(config.server.port, config.server.host)
|
||||
.onSuccess(server -> {
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
package su.xserver.iikocon;
|
||||
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.http.HttpMethod;
|
||||
import io.vertx.core.json.Json;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.Router;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import io.vertx.ext.web.client.WebClient;
|
||||
import io.vertx.ext.web.client.WebClientOptions;
|
||||
import io.vertx.ext.web.codec.BodyCodec;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
public class ProxyVerticle extends AbstractVerticle {
|
||||
|
||||
private WebClient webClient;
|
||||
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
webClient = WebClient.create(vertx, new WebClientOptions()
|
||||
.setSsl(true)
|
||||
.setTrustAll(true)
|
||||
.setVerifyHost(false));
|
||||
|
||||
Router router = Router.router(vertx);
|
||||
router.post("/api/proxy").handler(this::handlePost);
|
||||
router.get("/api/proxy").handler(this::handleGet);
|
||||
|
||||
int port = 8080;
|
||||
vertx.createHttpServer()
|
||||
.requestHandler(router)
|
||||
.listen(port).onComplete(http -> {
|
||||
if (http.succeeded()) {
|
||||
System.out.println("Proxy server started on port " + port);
|
||||
startPromise.complete();
|
||||
} else {
|
||||
startPromise.fail(http.cause());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handlePost(RoutingContext ctx) {
|
||||
String apiServer = System.getenv("IIKO_API_SERVER");
|
||||
String apiLogin = System.getenv("IIKO_API_LOGIN");
|
||||
String apiPass = System.getenv("IIKO_API_PASS");
|
||||
String externalEndpoint = System.getenv("IIKO_API_ENDPOINT");
|
||||
if (externalEndpoint == null || externalEndpoint.isBlank()) {
|
||||
externalEndpoint = "/your-endpoint";
|
||||
}
|
||||
|
||||
if (apiServer == null || apiLogin == null || apiPass == null) {
|
||||
fail(ctx, 500, "Missing required environment variables: IIKO_API_SERVER, IIKO_API_LOGIN, IIKO_API_PASS");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject body = ctx.body().asJsonObject();
|
||||
if (body == null) {
|
||||
fail(ctx, 400, "Request body must be JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
String signature = sha1(apiPass);
|
||||
String authUrl = "https://" + apiServer + ":443/resto/api/auth?login=" + apiLogin + "&pass=" + signature;
|
||||
String finalExternalEndpoint = externalEndpoint;
|
||||
webClient.getAbs(authUrl)
|
||||
.as(BodyCodec.string())
|
||||
.send()
|
||||
.onSuccess(authResp -> {
|
||||
if (authResp.statusCode() != 200) {
|
||||
fail(ctx, authResp.statusCode(), "Authentication failed: " + authResp.statusMessage());
|
||||
return;
|
||||
}
|
||||
String token = authResp.body();
|
||||
String targetUrl = "https://" + apiServer + finalExternalEndpoint;
|
||||
webClient.request(HttpMethod.POST, targetUrl)
|
||||
.putHeader("Content-Type", "application/json")
|
||||
.as(BodyCodec.jsonObject())
|
||||
.sendJsonObject(body)
|
||||
.onSuccess(apiResp -> {
|
||||
webClient.getAbs("https://" + apiServer + ":443/resto/api/logout?key=" + token)
|
||||
.send()
|
||||
.onFailure(err -> System.err.println("Logout failed: " + err.getMessage()));
|
||||
if (apiResp.statusCode() == 200) {
|
||||
ctx.response().setStatusCode(200).end(apiResp.body().encode());
|
||||
} else {
|
||||
fail(ctx, apiResp.statusCode(), "External API error: " + apiResp.statusMessage());
|
||||
}
|
||||
})
|
||||
.onFailure(err -> fail(ctx, 500, "Request to external API failed: " + err.getMessage()));
|
||||
})
|
||||
.onFailure(err -> fail(ctx, 500, "Auth request failed: " + err.getMessage()));
|
||||
}
|
||||
|
||||
private void handleGet(RoutingContext ctx) {
|
||||
String presetId = ctx.queryParam("presetId").stream().findFirst().orElse(null);
|
||||
String dateFrom = ctx.queryParam("dateFrom").stream().findFirst().orElse(null);
|
||||
String dateTo = ctx.queryParam("dateTo").stream().findFirst().orElse(null);
|
||||
String server = ctx.queryParam("server").stream().findFirst().orElse(null);
|
||||
String password = ctx.queryParam("password").stream().findFirst().orElse(null);
|
||||
String login = ctx.queryParam("login").stream().findFirst().orElse(null);
|
||||
String type = ctx.queryParam("type").stream().findFirst().orElse(null);
|
||||
String rootType = ctx.queryParam("rootType").stream().findFirst().orElse(null);
|
||||
|
||||
if (server == null || login == null || password == null) {
|
||||
fail(ctx, 400, "Missing required parameters: server, login, password");
|
||||
return;
|
||||
}
|
||||
|
||||
String signature = sha1(password);
|
||||
String authUrl = "https://" + server + ":443/resto/api/auth?login=" + login + "&pass=" + signature;
|
||||
webClient.getAbs(authUrl)
|
||||
.as(BodyCodec.string())
|
||||
.send()
|
||||
.onSuccess(authResp -> {
|
||||
if (authResp.statusCode() != 200) {
|
||||
fail(ctx, authResp.statusCode(), "Authentication failed: " + authResp.statusMessage());
|
||||
return;
|
||||
}
|
||||
String token = authResp.body();
|
||||
String dataUrl;
|
||||
if ("entity".equals(type)) {
|
||||
dataUrl = "https://" + server + "/resto/api/v2/entities/list?key=" + token;
|
||||
if (rootType != null && !rootType.isBlank()) {
|
||||
dataUrl += "&rootType=" + rootType;
|
||||
}
|
||||
} else {
|
||||
if (presetId == null || dateFrom == null || dateTo == null) {
|
||||
fail(ctx, 400, "Missing presetId, dateFrom or dateTo for report request");
|
||||
return;
|
||||
}
|
||||
dataUrl = "https://" + server + "/resto/api/v2/reports/olap/byPresetId/" + presetId +
|
||||
"?key=" + token + "&dateFrom=" + dateFrom + "&dateTo=" + dateTo;
|
||||
}
|
||||
System.out.println("URL: " + dataUrl);
|
||||
webClient.getAbs(dataUrl)
|
||||
.as(BodyCodec.jsonObject())
|
||||
.send()
|
||||
.onSuccess(dataResp -> {
|
||||
// logout (fire and forget)
|
||||
webClient.getAbs("https://" + server + ":443/resto/api/logout?key=" + token)
|
||||
.send()
|
||||
.onFailure(err -> System.err.println("Logout failed: " + err.getMessage()));
|
||||
if (dataResp.statusCode() == 200) {
|
||||
JsonObject responseBody = dataResp.body();
|
||||
if ("entity".equals(type)) {
|
||||
ctx.response().setStatusCode(200).end(responseBody.encode());
|
||||
} else {
|
||||
Object data = responseBody.getValue("data");
|
||||
if (data == null) {
|
||||
ctx.response().setStatusCode(200).end(responseBody.encode());
|
||||
} else {
|
||||
// data может быть массивом, объектом или другим типом
|
||||
ctx.response().setStatusCode(200).end(Json.encode(data));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fail(ctx, dataResp.statusCode(), "External API error: " + dataResp.statusMessage());
|
||||
}
|
||||
})
|
||||
.onFailure(err -> fail(ctx, 500, "Data request failed: " + err.getMessage()));
|
||||
})
|
||||
.onFailure(err -> fail(ctx, 500, "Auth request failed: " + err.getMessage()));
|
||||
}
|
||||
|
||||
private String sha1(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
byte[] digest = md.digest(input.getBytes());
|
||||
return HexFormat.of().formatHex(digest).toLowerCase();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void fail(RoutingContext ctx, int status, String message) {
|
||||
System.err.println("Error: " + message);
|
||||
ctx.response().setStatusCode(status).end(new JsonObject().put("error", message).encode());
|
||||
}
|
||||
}
|
||||
|
||||
// > GET /api/proxy?server=folk-amber-co.iiko.it&login=4444&password=4444&presetId=7ddc40c3-9d5f-408f-aa1e-652964b36c6c&dateFrom=2026-04-10&dateTo=2026-04-17 HTTP/1.1
|
||||
// > Host: localhost:8080
|
||||
// > access-token: ddb4ab653b9194ec1ea5448cee2a8a26282b0866c1d4a86e98e9b0f84bc91944
|
||||
// > User-Agent: v2raytun/ios
|
||||
// > X-App-Version: 2.4.3
|
||||
// > X-Device-Model: iPhone 11 Pro
|
||||
// > X-Device-OS: iOS
|
||||
// > X-HWID: HHS8JDJN-F2EB-HFBS-KMWX-234FA7B95JSC
|
||||
// > X-Ver-OS: 26.0
|
||||
// > Accept: */*
|
||||
@@ -8,6 +8,7 @@ public class AppConfig {
|
||||
public ServerConfig server;
|
||||
public DatabaseConfig database;
|
||||
public RedisConfig redis;
|
||||
public PhpMyAdminConfig pma;
|
||||
|
||||
public static AppConfig from(JsonObject json) {
|
||||
JsonObject resolved = json.copy();
|
||||
@@ -94,7 +95,8 @@ public class AppConfig {
|
||||
return new JsonObject()
|
||||
.put("server", server.json().getJsonObject("server"))
|
||||
.put("database", database.json().getJsonObject("database"))
|
||||
.put("redis", redis.json().getJsonObject("redis"));
|
||||
.put("redis", redis.json().getJsonObject("redis"))
|
||||
.put("pma", pma.json().getJsonObject("pma"));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/main/java/su/xserver/iikocon/handler/AdminHandler.java
Normal file
15
src/main/java/su/xserver/iikocon/handler/AdminHandler.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package su.xserver.iikocon;
|
||||
package su.xserver.iikocon.handler;
|
||||
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import io.vertx.ext.web.Session;
|
||||
import su.xserver.iikocon.service.UserService;
|
||||
|
||||
public class AuthHandler {
|
||||
private final UserService userService;
|
||||
@@ -28,10 +29,8 @@ public class AuthHandler {
|
||||
boolean passwordOk = userService.checkPassword(password, user.getString("password"));
|
||||
|
||||
if (passwordOk) {
|
||||
// Надёжное получение флага активности
|
||||
Boolean active = user.getBoolean("active");
|
||||
if (active == null) {
|
||||
// Если поле отсутствует, пробуем получить как Integer (на случай TINYINT)
|
||||
Integer activeInt = user.getInteger("active");
|
||||
active = activeInt != null && activeInt == 1;
|
||||
}
|
||||
@@ -41,9 +40,21 @@ public class AuthHandler {
|
||||
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.put("userId", user.getInteger("id"));
|
||||
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());
|
||||
} else {
|
||||
ctx.response().setStatusCode(401).end("Invalid credentials");
|
||||
185
src/main/java/su/xserver/iikocon/handler/RedisRateLimiter.java
Normal file
185
src/main/java/su/xserver/iikocon/handler/RedisRateLimiter.java
Normal 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(':', '.');
|
||||
}
|
||||
}
|
||||
115
src/main/java/su/xserver/iikocon/handler/SecurityHandler.java
Normal file
115
src/main/java/su/xserver/iikocon/handler/SecurityHandler.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package su.xserver.iikocon;
|
||||
package su.xserver.iikocon.handler;
|
||||
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import su.xserver.iikocon.service.UserService;
|
||||
|
||||
public class SetupHandler {
|
||||
private final UserService userService;
|
||||
@@ -51,8 +52,11 @@ public class SetupHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
String ip = ctx.request().remoteAddress().host();
|
||||
userService.createUser(login, email, password, ip, true).onComplete(cr -> {
|
||||
String clientIp = ctx.get("realClientIp");
|
||||
if (clientIp == null) {
|
||||
clientIp = ctx.request().remoteAddress().host();
|
||||
}
|
||||
userService.createUser(login, email, password, clientIp, true, "admin").onComplete(cr -> {
|
||||
if (cr.succeeded()) {
|
||||
ctx.response().setStatusCode(201)
|
||||
.end(new JsonObject().put("success", true).encode());
|
||||
266
src/main/java/su/xserver/iikocon/iiko/IikoHandler.java
Normal file
266
src/main/java/su/xserver/iikocon/iiko/IikoHandler.java
Normal 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();
|
||||
}
|
||||
}
|
||||
130
src/main/java/su/xserver/iikocon/iiko/IikoOlapClient.java
Normal file
130
src/main/java/su/xserver/iikocon/iiko/IikoOlapClient.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -42,20 +42,20 @@ public class HealthCheckService {
|
||||
});
|
||||
|
||||
// Database check
|
||||
healthCheckHandler.register("DataBase", future -> {
|
||||
healthCheckHandler.register("database", future -> {
|
||||
long start = System.currentTimeMillis();
|
||||
dbService.getPool().query("SELECT 1").execute()
|
||||
.onSuccess(rs -> {
|
||||
long time = System.currentTimeMillis() - start;
|
||||
JsonObject data = new JsonObject()
|
||||
.put("name", "database")
|
||||
.put("name", "DataBase")
|
||||
.put("latency_ms", time);
|
||||
future.complete(Status.OK(data));
|
||||
})
|
||||
.onFailure(err -> future.tryFail("DataBase ping failed: " + err.getMessage()));
|
||||
});
|
||||
|
||||
// Регистрируем endpoint /api/health
|
||||
// Endpoint /api/health
|
||||
router.get("/api/health").handler(healthCheckHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package su.xserver.iikocon;
|
||||
package su.xserver.iikocon.service;
|
||||
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
@@ -7,6 +7,9 @@ import io.vertx.sqlclient.Pool;
|
||||
import io.vertx.sqlclient.Row;
|
||||
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.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -18,61 +21,56 @@ public class RestaurantService {
|
||||
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() {
|
||||
String createTable = """
|
||||
CREATE TABLE IF NOT EXISTS restaurants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
login VARCHAR(255) NOT NULL,
|
||||
login VARCHAR(255) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
https BOOLEAN DEFAULT FALSE,
|
||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
""";
|
||||
|
||||
return pool.query(createTable).execute().mapEmpty();
|
||||
}
|
||||
|
||||
public Future<Long> countRestaurant() {
|
||||
return pool.query("SELECT COUNT(*) AS cnt FROM restaurants")
|
||||
.execute()
|
||||
.map(rows -> rows.iterator().next().getLong("cnt"));
|
||||
}
|
||||
|
||||
public Future<Void> createRestaurant(String name, String login, String password, String host) {
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("name", name);
|
||||
params.put("login", login);
|
||||
params.put("password", password);
|
||||
params.put("host", host);
|
||||
|
||||
public Future<Void> createRestaurant(String name, String login, String password, String host, boolean https) {
|
||||
String hashedPassword = hashPassword(password);
|
||||
Map<String, Object> params = Map.of(
|
||||
"name", name,
|
||||
"login", login,
|
||||
"password", hashedPassword,
|
||||
"host", host,
|
||||
"https", https
|
||||
);
|
||||
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)
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
public Future<JsonObject> findByName(String name) {
|
||||
return SqlTemplate.forQuery(pool,
|
||||
"SELECT id, name, login, password, created, updated, host FROM restaurants WHERE name = #{name}")
|
||||
.mapTo(row -> new JsonObject()
|
||||
.put("id", row.getInteger("id"))
|
||||
.put("name", row.getString("name"))
|
||||
.put("login", row.getString("login"))
|
||||
.put("password", row.getString("password"))
|
||||
.put("created", row.getLocalDateTime("created") != null ?
|
||||
row.getLocalDateTime("created").toString() : null)
|
||||
.put("updated", row.getLocalDateTime("updated") != null ?
|
||||
row.getLocalDateTime("updated").toString() : null)
|
||||
.put("host", row.getString("host")))
|
||||
.execute(Collections.singletonMap("name", name))
|
||||
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
|
||||
}
|
||||
|
||||
public Future<JsonArray> getAllRestaurants() {
|
||||
return pool.query("SELECT id, name, login, created, updated, host FROM restaurants ORDER BY id")
|
||||
return pool.query("SELECT id, name, login, created, updated, https, host FROM restaurants ORDER BY id")
|
||||
.execute()
|
||||
.map(rows -> {
|
||||
JsonArray array = new JsonArray();
|
||||
@@ -85,6 +83,7 @@ public class RestaurantService {
|
||||
row.getLocalDateTime("created").toString() : null)
|
||||
.put("updated", row.getLocalDateTime("updated") != null ?
|
||||
row.getLocalDateTime("updated").toString() : null)
|
||||
.put("https", row.getBoolean("https"))
|
||||
.put("host", row.getString("host")));
|
||||
}
|
||||
return array;
|
||||
@@ -93,12 +92,13 @@ public class RestaurantService {
|
||||
|
||||
public Future<JsonObject> findById(int id) {
|
||||
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()
|
||||
.put("id", row.getInteger("id"))
|
||||
.put("name", row.getString("name"))
|
||||
.put("login", row.getString("login"))
|
||||
.put("password", row.getString("password"))
|
||||
.put("https", row.getBoolean("https"))
|
||||
.put("host", row.getString("host"))
|
||||
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
|
||||
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null))
|
||||
@@ -106,24 +106,22 @@ public class RestaurantService {
|
||||
.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<>();
|
||||
params.put("id", id);
|
||||
params.put("name", name);
|
||||
params.put("login", login);
|
||||
params.put("host", host);
|
||||
|
||||
params.put("https", https);
|
||||
String sql;
|
||||
if (password != null && !password.isEmpty()) {
|
||||
params.put("password", password);
|
||||
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, password = #{password}, host = #{host} WHERE id = #{id}";
|
||||
String hashedPassword = hashPassword(password);
|
||||
params.put("password", hashedPassword);
|
||||
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, password = #{password}, host = #{host}, https = #{https} WHERE id = #{id}";
|
||||
} 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) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package su.xserver.iikocon;
|
||||
package su.xserver.iikocon.service;
|
||||
|
||||
import io.vertx.core.Future;
|
||||
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.templates.SqlTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class SettingsService {
|
||||
@@ -29,24 +30,6 @@ public class SettingsService {
|
||||
.put("type", "textarea")
|
||||
.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()
|
||||
.put("key", "enable_registration")
|
||||
.put("label", "Allow Public Registration")
|
||||
@@ -59,17 +42,6 @@ public class SettingsService {
|
||||
.put("description", "When enabled, only admins can access the site")
|
||||
.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()
|
||||
.put("key", "session_timeout_minutes")
|
||||
.put("label", "Session Timeout (minutes)")
|
||||
@@ -77,19 +49,39 @@ public class SettingsService {
|
||||
.put("type", "number")
|
||||
.put("required", true)
|
||||
);
|
||||
|
||||
// Безопасность и прокси
|
||||
meta.add(new JsonObject()
|
||||
.put("key", "logo_url")
|
||||
.put("label", "Logo URL")
|
||||
.put("description", "Path or URL to custom logo image")
|
||||
.put("key", "use_proxy_headers")
|
||||
.put("label", "Use Proxy Headers")
|
||||
.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")
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
public Future<JsonObject> getAllWithDefaults() {
|
||||
return getAll().compose(values -> {
|
||||
JsonObject result = new JsonObject();
|
||||
// Получаем метаданные, чтобы знать ключи
|
||||
return getMetadata().map(meta -> {
|
||||
for (Object item : meta) {
|
||||
JsonObject m = (JsonObject) item;
|
||||
@@ -107,13 +99,13 @@ public class SettingsService {
|
||||
return switch (key) {
|
||||
case "site_name" -> "Admin Panel";
|
||||
case "site_description" -> "";
|
||||
case "theme" -> "light";
|
||||
case "items_per_page" -> "20";
|
||||
case "enable_registration" -> "true";
|
||||
case "maintenance_mode" -> "false";
|
||||
case "default_language" -> "en";
|
||||
case "session_timeout_minutes" -> "60";
|
||||
case "logo_url" -> "";
|
||||
case "session_timeout_minutes" -> "120";
|
||||
case "use_proxy_headers" -> "false";
|
||||
case "trusted_proxies" -> "127.0.0.1";
|
||||
case "enable_csp" -> "true";
|
||||
case "allowed_hosts" -> "";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
@@ -128,13 +120,9 @@ public class SettingsService {
|
||||
return pool.query(createTable).execute()
|
||||
.compose(v -> setIfAbsent("site_name", "Admin Panel"))
|
||||
.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("maintenance_mode", "false"))
|
||||
.compose(v -> setIfAbsent("default_language", "en"))
|
||||
.compose(v -> setIfAbsent("session_timeout_minutes", "60"))
|
||||
.compose(v -> setIfAbsent("logo_url", "/assets/logo.png"))
|
||||
.compose(v -> setIfAbsent("session_timeout_minutes", "120"))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
@@ -170,4 +158,21 @@ public class SettingsService {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package su.xserver.iikocon;
|
||||
package su.xserver.iikocon.service;
|
||||
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
@@ -9,9 +9,7 @@ import io.vertx.sqlclient.Tuple;
|
||||
import io.vertx.sqlclient.templates.SqlTemplate;
|
||||
import org.mindrot.jbcrypt.BCrypt;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
|
||||
public class UserService {
|
||||
private final Pool pool;
|
||||
@@ -28,6 +26,8 @@ public class UserService {
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
active BOOLEAN DEFAULT FALSE,
|
||||
role VARCHAR(50) DEFAULT 'user',
|
||||
language VARCHAR(5) DEFAULT 'en',
|
||||
ip VARCHAR(45),
|
||||
created TIMESTAMP DEFAULT 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"));
|
||||
}
|
||||
|
||||
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());
|
||||
Map<String, Object> params = Map.of(
|
||||
"login", login,
|
||||
"email", email,
|
||||
"password", hash,
|
||||
"ip", ip,
|
||||
"active", active
|
||||
"active", active,
|
||||
"role", role
|
||||
);
|
||||
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)
|
||||
.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) {
|
||||
return createUser(login, email, password, ip, false);
|
||||
return createUser(login, email, password, ip, false, "user");
|
||||
}
|
||||
|
||||
public Future<Void> setActive(int id, boolean active) {
|
||||
@@ -68,7 +72,7 @@ public class UserService {
|
||||
}
|
||||
|
||||
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)
|
||||
.execute(Tuple.of(loginOrEmail, loginOrEmail))
|
||||
.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() {
|
||||
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()
|
||||
.map(rows -> {
|
||||
JsonArray array = new JsonArray();
|
||||
@@ -114,6 +95,8 @@ public class UserService {
|
||||
.put("login", row.getString("login"))
|
||||
.put("email", row.getString("email"))
|
||||
.put("active", row.getBoolean("active"))
|
||||
.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));
|
||||
@@ -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<>();
|
||||
params.put("id", id);
|
||||
params.put("login", login);
|
||||
params.put("email", email);
|
||||
params.put("ip", ip);
|
||||
if (role != null) params.put("role", role);
|
||||
|
||||
String sql;
|
||||
if (password != null && !password.isEmpty()) {
|
||||
String hash = BCrypt.hashpw(password, BCrypt.gensalt());
|
||||
params.put("password", hash);
|
||||
sql = "UPDATE users SET login = #{login}, email = #{email}, password = #{password}, ip = #{ip} WHERE id = #{id}";
|
||||
sql = "UPDATE users SET login = #{login}, email = #{email}, password = #{password}, ip = #{ip}"
|
||||
+ (role != null ? ", role = #{role}" : "") + " WHERE id = #{id}";
|
||||
} 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();
|
||||
}
|
||||
|
||||
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) {
|
||||
return SqlTemplate.forUpdate(pool, "DELETE FROM users WHERE id = #{id}")
|
||||
.execute(Collections.singletonMap("id", id))
|
||||
.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) {
|
||||
try {
|
||||
return BCrypt.checkpw(plain, hash);
|
||||
@@ -158,8 +196,10 @@ public class UserService {
|
||||
.put("id", row.getInteger("id"))
|
||||
.put("login", row.getString("login"))
|
||||
.put("email", row.getString("email"))
|
||||
.put("password", row.getString("password")) // ← ДОБАВИТЬ ЭТУ СТРОКУ
|
||||
.put("password", row.getString("password"))
|
||||
.put("active", row.getBoolean("active"))
|
||||
.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);
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
21
src/main/java/su/xserver/iikocon/test/DateRangeSetup.java
Normal file
21
src/main/java/su/xserver/iikocon/test/DateRangeSetup.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,10 @@
|
||||
"password": null,
|
||||
"maxPoolSize": 6,
|
||||
"maxWaitingHandlers": 6
|
||||
},
|
||||
"pma": {
|
||||
"enabled": false,
|
||||
"basePath": "/pma",
|
||||
"upstream": "http://localhost:80/"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user