Создание VUE шаблона
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -7,4 +7,12 @@ WebServer/tools/logs/*
|
|||||||
WebServer/soft/*
|
WebServer/soft/*
|
||||||
!WebServer/soft/soft.rar
|
!WebServer/soft/soft.rar
|
||||||
Backend/admin/frontend/wailsjs*
|
Backend/admin/frontend/wailsjs*
|
||||||
.cursorrules
|
|
||||||
|
# Frontend
|
||||||
|
front_vue/node_modules/
|
||||||
|
front_vue/dist/
|
||||||
|
front_vue/src/auto-imports.d.ts
|
||||||
|
front_vue/src/components.d.ts
|
||||||
|
|
||||||
|
# Cursor
|
||||||
|
.cursorrules
|
||||||
|
|||||||
13
front_vue/index.html
Normal file
13
front_vue/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>vServer Admin Panel</title>
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2185
front_vue/package-lock.json
generated
Normal file
2185
front_vue/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
front_vue/package.json
Normal file
23
front_vue/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "vserver-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.27",
|
||||||
|
"vue-i18n": "^11.2.8",
|
||||||
|
"vue-router": "^5.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
|
"unplugin-auto-import": "^21.0.0",
|
||||||
|
"unplugin-vue-components": "^31.0.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
front_vue/src/App.vue
Normal file
7
front_vue/src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup>
|
||||||
|
import MainLayout from '@design/layouts/MainLayout.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MainLayout />
|
||||||
|
</template>
|
||||||
6
front_vue/src/Core/api/index.js
Normal file
6
front_vue/src/Core/api/index.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { mockApi } from './mock.js'
|
||||||
|
import { wailsApi } from './wails.js'
|
||||||
|
|
||||||
|
const isWails = typeof window !== 'undefined' && window?.go?.admin?.App
|
||||||
|
|
||||||
|
export const api = isWails ? wailsApi : mockApi
|
||||||
42
front_vue/src/Core/api/mock-data/certs.json
Normal file
42
front_vue/src/Core/api/mock-data/certs.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"domain": "voxsel.ru",
|
||||||
|
"issuer": "R13",
|
||||||
|
"not_before": "2026-01-07",
|
||||||
|
"not_after": "2026-04-07",
|
||||||
|
"days_left": 79,
|
||||||
|
"is_expired": false,
|
||||||
|
"has_cert": true,
|
||||||
|
"dns_names": ["*.voxsel.com", "*.voxsel.ru", "voxsel.com", "voxsel.ru"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"domain": "finance.voxsel.ru",
|
||||||
|
"issuer": "E8",
|
||||||
|
"not_before": "2026-01-17",
|
||||||
|
"not_after": "2026-04-17",
|
||||||
|
"days_left": 89,
|
||||||
|
"is_expired": false,
|
||||||
|
"has_cert": true,
|
||||||
|
"dns_names": ["finance.voxsel.ru"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"domain": "test.local",
|
||||||
|
"issuer": "Let's Encrypt",
|
||||||
|
"not_before": "2025-01-01",
|
||||||
|
"not_after": "2025-03-31",
|
||||||
|
"days_left": 73,
|
||||||
|
"is_expired": false,
|
||||||
|
"has_cert": true,
|
||||||
|
"dns_names": ["test.local", "*.test.local", "test.com"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"domain": "api.example.com",
|
||||||
|
"issuer": "Let's Encrypt",
|
||||||
|
"not_before": "2024-10-01",
|
||||||
|
"not_after": "2024-12-30",
|
||||||
|
"days_left": -18,
|
||||||
|
"is_expired": true,
|
||||||
|
"has_cert": true,
|
||||||
|
"dns_names": ["api.example.com", "*.api.example.com"]
|
||||||
|
}
|
||||||
|
]
|
||||||
10
front_vue/src/Core/api/mock-data/config.json
Normal file
10
front_vue/src/Core/api/mock-data/config.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"Soft_Settings": {
|
||||||
|
"php_port": 8000,
|
||||||
|
"php_host": "localhost",
|
||||||
|
"mysql_port": 3306,
|
||||||
|
"mysql_host": "127.0.0.1",
|
||||||
|
"proxy_enabled": true,
|
||||||
|
"ACME_enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
29
front_vue/src/Core/api/mock-data/proxies.json
Normal file
29
front_vue/src/Core/api/mock-data/proxies.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"Enable": true,
|
||||||
|
"ExternalDomain": "git.example.ru",
|
||||||
|
"LocalAddress": "127.0.0.1",
|
||||||
|
"LocalPort": "3333",
|
||||||
|
"ServiceHTTPSuse": false,
|
||||||
|
"AutoHTTPS": true,
|
||||||
|
"AutoCreateSSL": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Enable": true,
|
||||||
|
"ExternalDomain": "api.example.com",
|
||||||
|
"LocalAddress": "127.0.0.1",
|
||||||
|
"LocalPort": "8080",
|
||||||
|
"ServiceHTTPSuse": true,
|
||||||
|
"AutoHTTPS": false,
|
||||||
|
"AutoCreateSSL": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Enable": false,
|
||||||
|
"ExternalDomain": "test.example.net",
|
||||||
|
"LocalAddress": "127.0.0.1",
|
||||||
|
"LocalPort": "5000",
|
||||||
|
"ServiceHTTPSuse": false,
|
||||||
|
"AutoHTTPS": false,
|
||||||
|
"AutoCreateSSL": false
|
||||||
|
}
|
||||||
|
]
|
||||||
7
front_vue/src/Core/api/mock-data/services.json
Normal file
7
front_vue/src/Core/api/mock-data/services.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{ "name": "HTTP", "status": true, "port": "80", "info": "" },
|
||||||
|
{ "name": "HTTPS", "status": true, "port": "443", "info": "" },
|
||||||
|
{ "name": "MySQL", "status": true, "port": "3306", "info": "" },
|
||||||
|
{ "name": "PHP", "status": true, "port": "8000-8003", "info": "" },
|
||||||
|
{ "name": "Proxy", "status": true, "port": "", "info": "2 из 3" }
|
||||||
|
]
|
||||||
29
front_vue/src/Core/api/mock-data/sites.json
Normal file
29
front_vue/src/Core/api/mock-data/sites.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Локальный сайт",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"alias": ["localhost"],
|
||||||
|
"status": "active",
|
||||||
|
"root_file": "index.html",
|
||||||
|
"root_file_routing": true,
|
||||||
|
"AutoCreateSSL": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Тестовый проект",
|
||||||
|
"host": "test.local",
|
||||||
|
"alias": ["*.test.local", "test.com"],
|
||||||
|
"status": "active",
|
||||||
|
"root_file": "index.php",
|
||||||
|
"root_file_routing": false,
|
||||||
|
"AutoCreateSSL": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "API сервис",
|
||||||
|
"host": "api.example.com",
|
||||||
|
"alias": ["*.api.example.com"],
|
||||||
|
"status": "inactive",
|
||||||
|
"root_file": "index.php",
|
||||||
|
"root_file_routing": true,
|
||||||
|
"AutoCreateSSL": true
|
||||||
|
}
|
||||||
|
]
|
||||||
20
front_vue/src/Core/api/mock-data/vaccess.json
Normal file
20
front_vue/src/Core/api/mock-data/vaccess.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"type": "Disable",
|
||||||
|
"type_file": ["*.php"],
|
||||||
|
"path_access": ["/uploads/*"],
|
||||||
|
"ip_list": [],
|
||||||
|
"exceptions_dir": [],
|
||||||
|
"url_error": "404"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Allow",
|
||||||
|
"type_file": [],
|
||||||
|
"path_access": ["/admin/*"],
|
||||||
|
"ip_list": ["192.168.1.100", "127.0.0.1"],
|
||||||
|
"exceptions_dir": ["/admin/public/*"],
|
||||||
|
"url_error": "404"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
121
front_vue/src/Core/api/mock.js
Normal file
121
front_vue/src/Core/api/mock.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import servicesData from './mock-data/services.json'
|
||||||
|
import sitesData from './mock-data/sites.json'
|
||||||
|
import proxiesData from './mock-data/proxies.json'
|
||||||
|
import configData from './mock-data/config.json'
|
||||||
|
import certsData from './mock-data/certs.json'
|
||||||
|
import vaccessData from './mock-data/vaccess.json'
|
||||||
|
|
||||||
|
const delay = (ms = 300) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
export const mockApi = {
|
||||||
|
async getAllServicesStatus() {
|
||||||
|
await delay(200)
|
||||||
|
return JSON.parse(JSON.stringify(servicesData))
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkServicesReady() {
|
||||||
|
await delay(100)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
async startServer() { await delay(500); return 'OK' },
|
||||||
|
async stopServer() { await delay(500); return 'OK' },
|
||||||
|
async startHTTPService() { await delay(300); return 'OK' },
|
||||||
|
async stopHTTPService() { await delay(300); return 'OK' },
|
||||||
|
async startHTTPSService() { await delay(300); return 'OK' },
|
||||||
|
async stopHTTPSService() { await delay(300); return 'OK' },
|
||||||
|
async startMySQLService() { await delay(300); return 'OK' },
|
||||||
|
async stopMySQLService() { await delay(300); return 'OK' },
|
||||||
|
async startPHPService() { await delay(300); return 'OK' },
|
||||||
|
async stopPHPService() { await delay(300); return 'OK' },
|
||||||
|
async enableProxyService() { await delay(200); return 'OK' },
|
||||||
|
async disableProxyService() { await delay(200); return 'OK' },
|
||||||
|
async enableACMEService() { await delay(200); return 'OK' },
|
||||||
|
async disableACMEService() { await delay(200); return 'OK' },
|
||||||
|
async restartAllServices() { await delay(1000); return 'OK' },
|
||||||
|
|
||||||
|
async getSitesList() {
|
||||||
|
await delay(200)
|
||||||
|
return JSON.parse(JSON.stringify(sitesData))
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNewSite(siteJSON) {
|
||||||
|
await delay(500)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSite(host) {
|
||||||
|
await delay(500)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
|
||||||
|
async openSiteFolder(host) {
|
||||||
|
await delay(100)
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadCertificate(host, certType, certDataBase64) {
|
||||||
|
await delay(300)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProxyList() {
|
||||||
|
await delay(200)
|
||||||
|
return JSON.parse(JSON.stringify(proxiesData))
|
||||||
|
},
|
||||||
|
|
||||||
|
async getVAccessRules(host, isProxy) {
|
||||||
|
await delay(200)
|
||||||
|
return JSON.parse(JSON.stringify(vaccessData))
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveVAccessRules(host, isProxy, configJSON) {
|
||||||
|
await delay(300)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
|
||||||
|
async getConfig() {
|
||||||
|
await delay(200)
|
||||||
|
return JSON.parse(JSON.stringify(configData))
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveConfig(configJSON) {
|
||||||
|
await delay(400)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadConfig() {
|
||||||
|
await delay(200)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
|
||||||
|
async obtainSSLCertificate(domain) {
|
||||||
|
await delay(1500)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
|
||||||
|
async obtainAllSSLCertificates() {
|
||||||
|
await delay(2000)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCertInfo(domain) {
|
||||||
|
await delay(200)
|
||||||
|
const cert = certsData.find(c => c.domain === domain)
|
||||||
|
return cert || { has_cert: false }
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllCertsInfo() {
|
||||||
|
await delay(300)
|
||||||
|
return JSON.parse(JSON.stringify(certsData))
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCertificate(domain) {
|
||||||
|
await delay(300)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadSSLCertificates() {
|
||||||
|
await delay(300)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
}
|
||||||
95
front_vue/src/Core/api/wails.js
Normal file
95
front_vue/src/Core/api/wails.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
const app = () => window.go.admin.App
|
||||||
|
|
||||||
|
export const wailsApi = {
|
||||||
|
async getAllServicesStatus() {
|
||||||
|
return await app().GetAllServicesStatus()
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkServicesReady() {
|
||||||
|
return await app().CheckServicesReady()
|
||||||
|
},
|
||||||
|
|
||||||
|
async startServer() { return await app().StartServer() },
|
||||||
|
async stopServer() { return await app().StopServer() },
|
||||||
|
async startHTTPService() { return await app().StartHTTPService() },
|
||||||
|
async stopHTTPService() { return await app().StopHTTPService() },
|
||||||
|
async startHTTPSService() { return await app().StartHTTPSService() },
|
||||||
|
async stopHTTPSService() { return await app().StopHTTPSService() },
|
||||||
|
async startMySQLService() { return await app().StartMySQLService() },
|
||||||
|
async stopMySQLService() { return await app().StopMySQLService() },
|
||||||
|
async startPHPService() { return await app().StartPHPService() },
|
||||||
|
async stopPHPService() { return await app().StopPHPService() },
|
||||||
|
async enableProxyService() { return await app().EnableProxyService() },
|
||||||
|
async disableProxyService() { return await app().DisableProxyService() },
|
||||||
|
async enableACMEService() { return await app().EnableACMEService() },
|
||||||
|
async disableACMEService() { return await app().DisableACMEService() },
|
||||||
|
async restartAllServices() { return await app().RestartAllServices() },
|
||||||
|
|
||||||
|
async getSitesList() {
|
||||||
|
return await app().GetSitesList()
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNewSite(siteJSON) {
|
||||||
|
return await app().CreateNewSite(siteJSON)
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSite(host) {
|
||||||
|
return await app().DeleteSite(host)
|
||||||
|
},
|
||||||
|
|
||||||
|
async openSiteFolder(host) {
|
||||||
|
return await app().OpenSiteFolder(host)
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadCertificate(host, certType, certDataBase64) {
|
||||||
|
return await app().UploadCertificate(host, certType, certDataBase64)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProxyList() {
|
||||||
|
return await app().GetProxyList()
|
||||||
|
},
|
||||||
|
|
||||||
|
async getVAccessRules(host, isProxy) {
|
||||||
|
return await app().GetVAccessRules(host, isProxy)
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveVAccessRules(host, isProxy, configJSON) {
|
||||||
|
return await app().SaveVAccessRules(host, isProxy, configJSON)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getConfig() {
|
||||||
|
return await app().GetConfig()
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveConfig(configJSON) {
|
||||||
|
return await app().SaveConfig(configJSON)
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadConfig() {
|
||||||
|
return await app().ReloadConfig()
|
||||||
|
},
|
||||||
|
|
||||||
|
async obtainSSLCertificate(domain) {
|
||||||
|
return await app().ObtainSSLCertificate(domain)
|
||||||
|
},
|
||||||
|
|
||||||
|
async obtainAllSSLCertificates() {
|
||||||
|
return await app().ObtainAllSSLCertificates()
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCertInfo(domain) {
|
||||||
|
return await app().GetCertInfo(domain)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllCertsInfo() {
|
||||||
|
return await app().GetAllCertsInfo()
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCertificate(domain) {
|
||||||
|
return await app().DeleteCertificate(domain)
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadSSLCertificates() {
|
||||||
|
return await app().ReloadSSLCertificates()
|
||||||
|
},
|
||||||
|
}
|
||||||
42
front_vue/src/Core/composables/useModal.js
Normal file
42
front_vue/src/Core/composables/useModal.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const isOpen = ref(false)
|
||||||
|
const title = ref('')
|
||||||
|
const content = ref(null)
|
||||||
|
const onSave = ref(null)
|
||||||
|
const onDelete = ref(null)
|
||||||
|
|
||||||
|
export function useModal() {
|
||||||
|
const open = (options = {}) => {
|
||||||
|
title.value = options.title || ''
|
||||||
|
content.value = options.content || null
|
||||||
|
onSave.value = options.onSave || null
|
||||||
|
onDelete.value = options.onDelete || null
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
isOpen.value = false
|
||||||
|
title.value = ''
|
||||||
|
content.value = null
|
||||||
|
onSave.value = null
|
||||||
|
onDelete.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (onSave.value) await onSave.value()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (onDelete.value) await onDelete.value()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen: readonly(isOpen),
|
||||||
|
title: readonly(title),
|
||||||
|
content: readonly(content),
|
||||||
|
hasDelete: computed(() => !!onDelete.value),
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
save,
|
||||||
|
remove,
|
||||||
|
}
|
||||||
|
}
|
||||||
37
front_vue/src/Core/composables/useNotification.js
Normal file
37
front_vue/src/Core/composables/useNotification.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const notifications = ref([])
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
export function useNotification() {
|
||||||
|
const show = (message, type = 'info', duration = 2000) => {
|
||||||
|
const id = nextId++
|
||||||
|
notifications.value.push({ id, message, type })
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
remove(id)
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (id) => {
|
||||||
|
const index = notifications.value.findIndex(n => n.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
notifications.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = (message, duration = 2000) => show(message, 'success', duration)
|
||||||
|
const error = (message, duration = 3000) => show(message, 'error', duration)
|
||||||
|
const info = (message, duration = 2000) => show(message, 'info', duration)
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: readonly(notifications),
|
||||||
|
show,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
info,
|
||||||
|
remove,
|
||||||
|
}
|
||||||
|
}
|
||||||
16
front_vue/src/Core/composables/useTheme.js
Normal file
16
front_vue/src/Core/composables/useTheme.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useAppStore } from '@core/stores/app.js'
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const initTheme = () => {
|
||||||
|
document.documentElement.setAttribute('data-theme', appStore.theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme: computed(() => appStore.theme),
|
||||||
|
isDark: computed(() => appStore.isDark),
|
||||||
|
toggleTheme: () => appStore.toggleTheme(),
|
||||||
|
initTheme,
|
||||||
|
}
|
||||||
|
}
|
||||||
17
front_vue/src/Core/composables/useWailsEvents.js
Normal file
17
front_vue/src/Core/composables/useWailsEvents.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function useWailsEvents() {
|
||||||
|
const isWails = typeof window !== 'undefined' && window?.runtime?.EventsOn
|
||||||
|
|
||||||
|
const on = (eventName, callback) => {
|
||||||
|
if (isWails) {
|
||||||
|
window.runtime.EventsOn(eventName, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const off = (eventName) => {
|
||||||
|
if (isWails && window.runtime.EventsOff) {
|
||||||
|
window.runtime.EventsOff(eventName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { on, off, isWails }
|
||||||
|
}
|
||||||
45
front_vue/src/Core/constants.js
Normal file
45
front_vue/src/Core/constants.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export const SERVICE_NAMES = {
|
||||||
|
HTTP: 'http',
|
||||||
|
HTTPS: 'https',
|
||||||
|
MYSQL: 'mysql',
|
||||||
|
PHP: 'php',
|
||||||
|
PROXY: 'proxy',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SITE_STATUS = {
|
||||||
|
ACTIVE: 'active',
|
||||||
|
INACTIVE: 'inactive',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROXY_STATUS = {
|
||||||
|
ENABLED: 'enable',
|
||||||
|
DISABLED: 'disable',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VACCESS_TYPE = {
|
||||||
|
ALLOW: 'Allow',
|
||||||
|
DISABLE: 'Disable',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CERT_MODE = {
|
||||||
|
NONE: 'none',
|
||||||
|
AUTO: 'auto',
|
||||||
|
UPLOAD: 'upload',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEME = {
|
||||||
|
DARK: 'dark',
|
||||||
|
LIGHT: 'light',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCALE = {
|
||||||
|
RU: 'ru',
|
||||||
|
EN: 'en',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
THEME: 'vserver-theme',
|
||||||
|
LOCALE: 'vserver-locale',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTO_REFRESH_INTERVAL = 5000
|
||||||
204
front_vue/src/Core/i18n/en.json
Normal file
204
front_vue/src/Core/i18n/en.json
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "vServer Admin Panel",
|
||||||
|
"logo": "vServer",
|
||||||
|
"footer": "vServer Admin Panel © 2025 | Author: Sumaneev Roman",
|
||||||
|
"loading": "Starting vServer..."
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"settings": "Settings"
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"minimize": "Minimize",
|
||||||
|
"maximize": "Maximize",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"running": "Server is running",
|
||||||
|
"stopped": "Server is stopped",
|
||||||
|
"start": "Start",
|
||||||
|
"stop": "Stop"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"title": "Services Status",
|
||||||
|
"port": "Port",
|
||||||
|
"ports": "Ports",
|
||||||
|
"rules": "Rules",
|
||||||
|
"starting": "Starting",
|
||||||
|
"http": "HTTP",
|
||||||
|
"https": "HTTPS",
|
||||||
|
"mysql": "MySQL",
|
||||||
|
"php": "PHP",
|
||||||
|
"proxy": "Proxy"
|
||||||
|
},
|
||||||
|
"sites": {
|
||||||
|
"title": "Sites List",
|
||||||
|
"add": "Add Site",
|
||||||
|
"create": "Create New Site",
|
||||||
|
"createBtn": "Create Site",
|
||||||
|
"createDesc": "Fill in the site information and optionally upload SSL certificates",
|
||||||
|
"name": "Name",
|
||||||
|
"host": "Host",
|
||||||
|
"alias": "Alias",
|
||||||
|
"status": "Status",
|
||||||
|
"rootFile": "Root File",
|
||||||
|
"actions": "Actions",
|
||||||
|
"openFolder": "Open Folder",
|
||||||
|
"editVaccess": "vAccess",
|
||||||
|
"edit": "Edit",
|
||||||
|
"formName": "Site Name",
|
||||||
|
"formNamePlaceholder": "My new site",
|
||||||
|
"formHost": "Host (domain)",
|
||||||
|
"formHostPlaceholder": "example.com",
|
||||||
|
"formHostHint": "Enter domain without protocol (e.g.: example.com or 192.168.1.100)",
|
||||||
|
"formAlias": "Alias (aliases)",
|
||||||
|
"formAliasPlaceholder": "*.example.com, www.example.com, alias.com",
|
||||||
|
"formAliasHint": "Enter aliases separated by commas",
|
||||||
|
"formRootFile": "Root file",
|
||||||
|
"formStatus": "Status",
|
||||||
|
"formRouting": "Root file routing",
|
||||||
|
"formRoutingHint": "If enabled, all requests to non-existent files will be redirected to the root file",
|
||||||
|
"deleteTitle": "Delete Site",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete site \"{name}\" ({host})?",
|
||||||
|
"deleteWarning": "This action is IRREVERSIBLE!"
|
||||||
|
},
|
||||||
|
"proxies": {
|
||||||
|
"title": "Proxy Services",
|
||||||
|
"add": "Add Proxy",
|
||||||
|
"create": "Create Proxy Service",
|
||||||
|
"createBtn": "Create Proxy",
|
||||||
|
"createDesc": "Configure proxying of an external domain to a local service",
|
||||||
|
"externalDomain": "External Domain",
|
||||||
|
"localAddress": "Local Address",
|
||||||
|
"localPort": "Local Port",
|
||||||
|
"httpsCol": "HTTPS",
|
||||||
|
"autoHttps": "Auto HTTPS",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions",
|
||||||
|
"formDomain": "External Domain",
|
||||||
|
"formDomainPlaceholder": "example.com",
|
||||||
|
"formDomainHint": "Domain that will receive requests (e.g.: git.example.ru)",
|
||||||
|
"formLocalAddr": "Local Address",
|
||||||
|
"formLocalPort": "Local Port",
|
||||||
|
"formServiceHttps": "HTTPS to service",
|
||||||
|
"formServiceHttpsHint": "Use HTTPS when connecting to the local service",
|
||||||
|
"formAutoHttps": "Auto HTTPS",
|
||||||
|
"formAutoHttpsHint": "Automatically redirect HTTP requests to HTTPS",
|
||||||
|
"deleteTitle": "Delete Proxy",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete proxy \"{domain}\"?"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Server Settings",
|
||||||
|
"save": "Save & Restart",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"restarting": "Restarting services...",
|
||||||
|
"mysql": "MySQL Server",
|
||||||
|
"php": "PHP Server",
|
||||||
|
"proxyManager": "Proxy Manager",
|
||||||
|
"certManager": "Cert Manager",
|
||||||
|
"hostAddr": "Host Address",
|
||||||
|
"port": "Port",
|
||||||
|
"proxyHint": "Applied instantly without server restart. When disabled, all proxy rules will be turned off.",
|
||||||
|
"certHint": "Automatic SSL certificate issuance from Let's Encrypt for domains with \"Auto SSL\" enabled."
|
||||||
|
},
|
||||||
|
"vaccess": {
|
||||||
|
"title": "vAccess Access Rules",
|
||||||
|
"subtitle": "Manage access rules for the site",
|
||||||
|
"save": "Save Changes",
|
||||||
|
"addRule": "Add Rule",
|
||||||
|
"rulesTab": "Access Rules",
|
||||||
|
"helpTab": "Help",
|
||||||
|
"type": "Type",
|
||||||
|
"files": "Extensions",
|
||||||
|
"paths": "Access Paths",
|
||||||
|
"ips": "IP Addresses",
|
||||||
|
"exceptions": "Exceptions",
|
||||||
|
"error": "Error",
|
||||||
|
"empty": "No access rules",
|
||||||
|
"emptyDesc": "Add the first rule to start managing access",
|
||||||
|
"createRule": "Create Rule",
|
||||||
|
"allow": "Allow",
|
||||||
|
"disable": "Disable",
|
||||||
|
"helpPrinciple": "How it works",
|
||||||
|
"helpParams": "Rule Parameters",
|
||||||
|
"helpPatterns": "Patterns",
|
||||||
|
"helpExamples": "Rule Examples"
|
||||||
|
},
|
||||||
|
"certs": {
|
||||||
|
"title": "Certificate Management",
|
||||||
|
"subtitle": "View and manage SSL certificates for the domain",
|
||||||
|
"status": "Status",
|
||||||
|
"issuer": "Issuer",
|
||||||
|
"issued": "Issued",
|
||||||
|
"expires": "Expires",
|
||||||
|
"active": "Active",
|
||||||
|
"expired": "Expired",
|
||||||
|
"noCert": "No certificate",
|
||||||
|
"localDomain": "Local domain",
|
||||||
|
"wildcardCover": "Covered by wildcard",
|
||||||
|
"issue": "Issue Certificate",
|
||||||
|
"issueDirect": "Issue Direct",
|
||||||
|
"renew": "Renew",
|
||||||
|
"delete": "Delete",
|
||||||
|
"daysLeft": "{n} days",
|
||||||
|
"mode": "Certificate Mode",
|
||||||
|
"modeNone": "No certificate (fallback)",
|
||||||
|
"modeAuto": "Automatic certificate creation",
|
||||||
|
"modeUpload": "Upload certificate files",
|
||||||
|
"certFile": "Certificate (*.crt)",
|
||||||
|
"keyFile": "Private Key (*.key)",
|
||||||
|
"caFile": "CA Bundle (*.crt)",
|
||||||
|
"caHint": "CA Bundle is optional but recommended for full certificate chain",
|
||||||
|
"selectFile": "Choose file..."
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"back": "Back",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"create": "Create",
|
||||||
|
"edit": "Edit",
|
||||||
|
"add": "Add",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"active": "active",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"required": "Required field",
|
||||||
|
"basicInfo": "Basic Information",
|
||||||
|
"sslCerts": "SSL Certificates (optional)",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"errorPrefix": "Error"
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"settingsSaved": "Settings saved and services restarted!",
|
||||||
|
"settingsTestMode": "Settings saved (test mode)",
|
||||||
|
"dataSaved": "Data saved (test mode)",
|
||||||
|
"siteCreated": "Site created successfully!",
|
||||||
|
"siteDeleted": "Site deleted successfully!",
|
||||||
|
"proxyDeleted": "Proxy deleted successfully!",
|
||||||
|
"changesSaved": "Changes saved and applied!",
|
||||||
|
"serversRestarted": "Servers restarted!",
|
||||||
|
"certIssued": "Certificate issued successfully!",
|
||||||
|
"certRenewed": "Certificate renewed successfully!",
|
||||||
|
"certDeleted": "Certificate deleted",
|
||||||
|
"proxyEnabled": "Proxy Manager enabled",
|
||||||
|
"proxyDisabled": "Proxy Manager disabled",
|
||||||
|
"certManagerEnabled": "Cert Manager enabled",
|
||||||
|
"certManagerDisabled": "Cert Manager disabled",
|
||||||
|
"deletingProxy": "Deleting proxy...",
|
||||||
|
"deletingSite": "Deleting site...",
|
||||||
|
"requestingCert": "Requesting certificate...",
|
||||||
|
"restartingHttp": "Restarting HTTP/HTTPS..."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"dark": "Dark theme",
|
||||||
|
"light": "Light theme"
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"ru": "Русский",
|
||||||
|
"en": "English"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
front_vue/src/Core/i18n/index.js
Normal file
15
front_vue/src/Core/i18n/index.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import ru from './ru.json'
|
||||||
|
import en from './en.json'
|
||||||
|
import { STORAGE_KEYS, LOCALE } from '@core/constants.js'
|
||||||
|
|
||||||
|
const savedLocale = localStorage.getItem(STORAGE_KEYS.LOCALE) || LOCALE.RU
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: savedLocale,
|
||||||
|
fallbackLocale: LOCALE.RU,
|
||||||
|
messages: { ru, en },
|
||||||
|
})
|
||||||
|
|
||||||
|
export default i18n
|
||||||
204
front_vue/src/Core/i18n/ru.json
Normal file
204
front_vue/src/Core/i18n/ru.json
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "vServer Admin Panel",
|
||||||
|
"logo": "vServer",
|
||||||
|
"footer": "vServer Admin Panel © 2025 | Автор: Суманеев Роман",
|
||||||
|
"loading": "Запуск vServer..."
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Главная",
|
||||||
|
"settings": "Настройки"
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"minimize": "Свернуть",
|
||||||
|
"maximize": "Развернуть",
|
||||||
|
"close": "Закрыть"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"running": "Сервер запущен",
|
||||||
|
"stopped": "Сервер остановлен",
|
||||||
|
"start": "Запустить",
|
||||||
|
"stop": "Остановить"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"title": "Статус сервисов",
|
||||||
|
"port": "Порт",
|
||||||
|
"ports": "Порты",
|
||||||
|
"rules": "Правил",
|
||||||
|
"starting": "Запуск",
|
||||||
|
"http": "HTTP",
|
||||||
|
"https": "HTTPS",
|
||||||
|
"mysql": "MySQL",
|
||||||
|
"php": "PHP",
|
||||||
|
"proxy": "Proxy"
|
||||||
|
},
|
||||||
|
"sites": {
|
||||||
|
"title": "Список сайтов",
|
||||||
|
"add": "Добавить сайт",
|
||||||
|
"create": "Создание нового сайта",
|
||||||
|
"createBtn": "Создать сайт",
|
||||||
|
"createDesc": "Заполните информацию о сайте и при необходимости загрузите SSL сертификаты",
|
||||||
|
"name": "Имя",
|
||||||
|
"host": "Host",
|
||||||
|
"alias": "Alias",
|
||||||
|
"status": "Статус",
|
||||||
|
"rootFile": "Root File",
|
||||||
|
"actions": "Действия",
|
||||||
|
"openFolder": "Открыть папку",
|
||||||
|
"editVaccess": "vAccess",
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"formName": "Название сайта",
|
||||||
|
"formNamePlaceholder": "Мой новый сайт",
|
||||||
|
"formHost": "Host (домен)",
|
||||||
|
"formHostPlaceholder": "example.com",
|
||||||
|
"formHostHint": "Введите домен без протокола (например: example.com или 192.168.1.100)",
|
||||||
|
"formAlias": "Alias (псевдонимы)",
|
||||||
|
"formAliasPlaceholder": "*.example.com, www.example.com, alias.com",
|
||||||
|
"formAliasHint": "Введите псевдонимы через запятую",
|
||||||
|
"formRootFile": "Root файл",
|
||||||
|
"formStatus": "Статус",
|
||||||
|
"formRouting": "Root file routing",
|
||||||
|
"formRoutingHint": "Если включено, все запросы к несуществующим файлам будут перенаправляться на root файл",
|
||||||
|
"deleteTitle": "Удалить сайт",
|
||||||
|
"deleteConfirm": "Вы действительно хотите удалить сайт \"{name}\" ({host})?",
|
||||||
|
"deleteWarning": "Это действие НЕОБРАТИМО!"
|
||||||
|
},
|
||||||
|
"proxies": {
|
||||||
|
"title": "Прокси сервисы",
|
||||||
|
"add": "Добавить прокси",
|
||||||
|
"create": "Создание прокси сервиса",
|
||||||
|
"createBtn": "Создать прокси",
|
||||||
|
"createDesc": "Настройте проксирование внешнего домена на локальный сервис",
|
||||||
|
"externalDomain": "Внешний домен",
|
||||||
|
"localAddress": "Локальный адрес",
|
||||||
|
"localPort": "Локальный порт",
|
||||||
|
"httpsCol": "HTTPS",
|
||||||
|
"autoHttps": "Auto HTTPS",
|
||||||
|
"status": "Статус",
|
||||||
|
"actions": "Действия",
|
||||||
|
"formDomain": "Внешний домен",
|
||||||
|
"formDomainPlaceholder": "example.com",
|
||||||
|
"formDomainHint": "Домен, на который будут приходить запросы (например: git.example.ru)",
|
||||||
|
"formLocalAddr": "Локальный адрес",
|
||||||
|
"formLocalPort": "Локальный порт",
|
||||||
|
"formServiceHttps": "HTTPS к сервису",
|
||||||
|
"formServiceHttpsHint": "Использовать HTTPS при подключении к локальному сервису",
|
||||||
|
"formAutoHttps": "Авто HTTPS",
|
||||||
|
"formAutoHttpsHint": "Автоматически перенаправлять HTTP запросы на HTTPS",
|
||||||
|
"deleteTitle": "Удалить прокси",
|
||||||
|
"deleteConfirm": "Вы действительно хотите удалить прокси \"{domain}\"?"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Настройки серверов",
|
||||||
|
"save": "Сохранить и перезапустить",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"restarting": "Перезапуск сервисов...",
|
||||||
|
"mysql": "MySQL сервер",
|
||||||
|
"php": "PHP сервер",
|
||||||
|
"proxyManager": "Proxy Manager",
|
||||||
|
"certManager": "Cert Manager",
|
||||||
|
"hostAddr": "Host адрес",
|
||||||
|
"port": "Порт",
|
||||||
|
"proxyHint": "Применяется моментально без перезапуска серверов. При выключении все прокси правила будут отключены.",
|
||||||
|
"certHint": "Автоматическое получение SSL сертификатов от Let's Encrypt для доменов с включённым \"Авто SSL\"."
|
||||||
|
},
|
||||||
|
"vaccess": {
|
||||||
|
"title": "Правила доступа vAccess",
|
||||||
|
"subtitle": "Управление правилами доступа для сайта",
|
||||||
|
"save": "Сохранить изменения",
|
||||||
|
"addRule": "Добавить правило",
|
||||||
|
"rulesTab": "Правила доступа",
|
||||||
|
"helpTab": "Инструкция",
|
||||||
|
"type": "Тип",
|
||||||
|
"files": "Расширения",
|
||||||
|
"paths": "Пути доступа",
|
||||||
|
"ips": "IP адреса",
|
||||||
|
"exceptions": "Исключения",
|
||||||
|
"error": "Ошибка",
|
||||||
|
"empty": "Нет правил доступа",
|
||||||
|
"emptyDesc": "Добавьте первое правило, чтобы начать управление доступом",
|
||||||
|
"createRule": "Создать правило",
|
||||||
|
"allow": "Allow",
|
||||||
|
"disable": "Disable",
|
||||||
|
"helpPrinciple": "Принцип работы",
|
||||||
|
"helpParams": "Параметры правил",
|
||||||
|
"helpPatterns": "Паттерны",
|
||||||
|
"helpExamples": "Примеры правил"
|
||||||
|
},
|
||||||
|
"certs": {
|
||||||
|
"title": "Управление сертификатами",
|
||||||
|
"subtitle": "Просмотр и управление SSL сертификатами для домена",
|
||||||
|
"status": "Статус",
|
||||||
|
"issuer": "Издатель",
|
||||||
|
"issued": "Выдан",
|
||||||
|
"expires": "Истекает",
|
||||||
|
"active": "Активен",
|
||||||
|
"expired": "Истёк",
|
||||||
|
"noCert": "Нет сертификата",
|
||||||
|
"localDomain": "Локальный домен",
|
||||||
|
"wildcardCover": "Покрыт wildcard",
|
||||||
|
"issue": "Выпустить сертификат",
|
||||||
|
"issueDirect": "Выпустить прямой",
|
||||||
|
"renew": "Перевыпустить",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"daysLeft": "{n} дн.",
|
||||||
|
"mode": "Режим сертификата",
|
||||||
|
"modeNone": "Без сертификата (fallback)",
|
||||||
|
"modeAuto": "Автоматическое создание сертификата",
|
||||||
|
"modeUpload": "Загрузить файлы сертификата",
|
||||||
|
"certFile": "Certificate (*.crt)",
|
||||||
|
"keyFile": "Private Key (*.key)",
|
||||||
|
"caFile": "CA Bundle (*.crt)",
|
||||||
|
"caHint": "CA Bundle опционален, но рекомендуется для полной цепочки сертификации",
|
||||||
|
"selectFile": "Выберите файл..."
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"back": "Назад",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"create": "Создать",
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"add": "Добавить",
|
||||||
|
"enabled": "Включён",
|
||||||
|
"disabled": "Отключён",
|
||||||
|
"yes": "Да",
|
||||||
|
"no": "Нет",
|
||||||
|
"active": "active",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"required": "Обязательное поле",
|
||||||
|
"basicInfo": "Основная информация",
|
||||||
|
"sslCerts": "SSL Сертификаты (опционально)",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"errorPrefix": "Ошибка"
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"settingsSaved": "Настройки сохранены и сервисы перезапущены!",
|
||||||
|
"settingsTestMode": "Настройки сохранены (тестовый режим)",
|
||||||
|
"dataSaved": "Данные сохранены (тестовый режим)",
|
||||||
|
"siteCreated": "Сайт успешно создан!",
|
||||||
|
"siteDeleted": "Сайт успешно удалён!",
|
||||||
|
"proxyDeleted": "Прокси успешно удалён!",
|
||||||
|
"changesSaved": "Изменения сохранены и применены!",
|
||||||
|
"serversRestarted": "Серверы перезапущены!",
|
||||||
|
"certIssued": "Сертификат успешно выпущен!",
|
||||||
|
"certRenewed": "Сертификат успешно перевыпущен!",
|
||||||
|
"certDeleted": "Сертификат удалён",
|
||||||
|
"proxyEnabled": "Proxy Manager включен",
|
||||||
|
"proxyDisabled": "Proxy Manager отключен",
|
||||||
|
"certManagerEnabled": "Cert Manager включен",
|
||||||
|
"certManagerDisabled": "Cert Manager отключен",
|
||||||
|
"deletingProxy": "Удаление прокси...",
|
||||||
|
"deletingSite": "Удаление сайта...",
|
||||||
|
"requestingCert": "Запрос сертификата...",
|
||||||
|
"restartingHttp": "Перезапуск HTTP/HTTPS..."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"dark": "Тёмная тема",
|
||||||
|
"light": "Светлая тема"
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"ru": "Русский",
|
||||||
|
"en": "English"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
front_vue/src/Core/router/index.js
Normal file
55
front_vue/src/Core/router/index.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('@design/views/DashboardView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('@design/views/SettingsView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/sites/create',
|
||||||
|
name: 'site-create',
|
||||||
|
component: () => import('@design/views/SiteCreateView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/proxies/create',
|
||||||
|
name: 'proxy-create',
|
||||||
|
component: () => import('@design/views/ProxyCreateView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/sites/edit/:host',
|
||||||
|
name: 'site-edit',
|
||||||
|
component: () => import('@design/views/SiteEditView.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/proxies/edit/:domain',
|
||||||
|
name: 'proxy-edit',
|
||||||
|
component: () => import('@design/views/ProxyEditView.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/vaccess/:host',
|
||||||
|
name: 'vaccess',
|
||||||
|
component: () => import('@design/views/VAccessView.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/certs/:host',
|
||||||
|
name: 'certs',
|
||||||
|
component: () => import('@design/views/CertManagerView.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
36
front_vue/src/Core/stores/app.js
Normal file
36
front_vue/src/Core/stores/app.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { STORAGE_KEYS, THEME, LOCALE } from '@core/constants.js'
|
||||||
|
|
||||||
|
export const useAppStore = defineStore('app', {
|
||||||
|
state: () => ({
|
||||||
|
theme: localStorage.getItem(STORAGE_KEYS.THEME) || THEME.DARK,
|
||||||
|
locale: localStorage.getItem(STORAGE_KEYS.LOCALE) || LOCALE.RU,
|
||||||
|
loading: true,
|
||||||
|
serverRunning: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isDark: (state) => state.theme === THEME.DARK,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
toggleTheme() {
|
||||||
|
this.theme = this.isDark ? THEME.LIGHT : THEME.DARK
|
||||||
|
localStorage.setItem(STORAGE_KEYS.THEME, this.theme)
|
||||||
|
document.documentElement.setAttribute('data-theme', this.theme)
|
||||||
|
},
|
||||||
|
|
||||||
|
setLocale(locale) {
|
||||||
|
this.locale = locale
|
||||||
|
localStorage.setItem(STORAGE_KEYS.LOCALE, locale)
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading(value) {
|
||||||
|
this.loading = value
|
||||||
|
},
|
||||||
|
|
||||||
|
setServerRunning(value) {
|
||||||
|
this.serverRunning = value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
45
front_vue/src/Core/stores/certs.js
Normal file
45
front_vue/src/Core/stores/certs.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { api } from '@core/api/index.js'
|
||||||
|
|
||||||
|
export const useCertsStore = defineStore('certs', {
|
||||||
|
state: () => ({
|
||||||
|
list: [],
|
||||||
|
loaded: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async loadAll() {
|
||||||
|
const data = await api.getAllCertsInfo()
|
||||||
|
if (data) {
|
||||||
|
this.list = data
|
||||||
|
this.loaded = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getInfo(domain) {
|
||||||
|
return await api.getCertInfo(domain)
|
||||||
|
},
|
||||||
|
|
||||||
|
async issue(domain) {
|
||||||
|
const result = await api.obtainSSLCertificate(domain)
|
||||||
|
await this.loadAll()
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
async renew(domain) {
|
||||||
|
const result = await api.obtainSSLCertificate(domain)
|
||||||
|
await this.loadAll()
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(domain) {
|
||||||
|
const result = await api.deleteCertificate(domain)
|
||||||
|
await this.loadAll()
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
async reload() {
|
||||||
|
return await api.reloadSSLCertificates()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
51
front_vue/src/Core/stores/config.js
Normal file
51
front_vue/src/Core/stores/config.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { api } from '@core/api/index.js'
|
||||||
|
|
||||||
|
export const useConfigStore = defineStore('config', {
|
||||||
|
state: () => ({
|
||||||
|
data: null,
|
||||||
|
loaded: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
softSettings: (state) => state.data?.Soft_Settings || {},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async load() {
|
||||||
|
const config = await api.getConfig()
|
||||||
|
if (config) {
|
||||||
|
this.data = config
|
||||||
|
this.loaded = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(configData) {
|
||||||
|
const result = await api.saveConfig(JSON.stringify(configData, null, 4))
|
||||||
|
if (result && !String(result).startsWith('Error')) {
|
||||||
|
this.data = configData
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
async enableProxy() {
|
||||||
|
return await api.enableProxyService()
|
||||||
|
},
|
||||||
|
|
||||||
|
async disableProxy() {
|
||||||
|
return await api.disableProxyService()
|
||||||
|
},
|
||||||
|
|
||||||
|
async enableACME() {
|
||||||
|
return await api.enableACMEService()
|
||||||
|
},
|
||||||
|
|
||||||
|
async disableACME() {
|
||||||
|
return await api.disableACMEService()
|
||||||
|
},
|
||||||
|
|
||||||
|
async restartAll() {
|
||||||
|
return await api.restartAllServices()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
19
front_vue/src/Core/stores/proxies.js
Normal file
19
front_vue/src/Core/stores/proxies.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { api } from '@core/api/index.js'
|
||||||
|
|
||||||
|
export const useProxiesStore = defineStore('proxies', {
|
||||||
|
state: () => ({
|
||||||
|
list: [],
|
||||||
|
loaded: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async load() {
|
||||||
|
const data = await api.getProxyList()
|
||||||
|
if (data) {
|
||||||
|
this.list = data
|
||||||
|
this.loaded = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
41
front_vue/src/Core/stores/services.js
Normal file
41
front_vue/src/Core/stores/services.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { api } from '@core/api/index.js'
|
||||||
|
|
||||||
|
export const useServicesStore = defineStore('services', {
|
||||||
|
state: () => ({
|
||||||
|
list: [],
|
||||||
|
loaded: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async load() {
|
||||||
|
const data = await api.getAllServicesStatus()
|
||||||
|
if (data) {
|
||||||
|
this.list = Array.isArray(data) ? data : [data]
|
||||||
|
this.loaded = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async startService(name) {
|
||||||
|
const methods = {
|
||||||
|
HTTP: () => api.startHTTPService(),
|
||||||
|
HTTPS: () => api.startHTTPSService(),
|
||||||
|
MySQL: () => api.startMySQLService(),
|
||||||
|
PHP: () => api.startPHPService(),
|
||||||
|
}
|
||||||
|
if (methods[name]) await methods[name]()
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopService(name) {
|
||||||
|
const methods = {
|
||||||
|
HTTP: () => api.stopHTTPService(),
|
||||||
|
HTTPS: () => api.stopHTTPSService(),
|
||||||
|
MySQL: () => api.stopMySQLService(),
|
||||||
|
PHP: () => api.stopPHPService(),
|
||||||
|
}
|
||||||
|
if (methods[name]) await methods[name]()
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
43
front_vue/src/Core/stores/sites.js
Normal file
43
front_vue/src/Core/stores/sites.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { api } from '@core/api/index.js'
|
||||||
|
|
||||||
|
export const useSitesStore = defineStore('sites', {
|
||||||
|
state: () => ({
|
||||||
|
list: [],
|
||||||
|
loaded: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async load() {
|
||||||
|
const data = await api.getSitesList()
|
||||||
|
if (data) {
|
||||||
|
this.list = data
|
||||||
|
this.loaded = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(siteData) {
|
||||||
|
const result = await api.createNewSite(JSON.stringify(siteData))
|
||||||
|
if (result && !String(result).startsWith('Error')) {
|
||||||
|
await this.load()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(host) {
|
||||||
|
const result = await api.deleteSite(host)
|
||||||
|
if (result && !String(result).startsWith('Error')) {
|
||||||
|
await this.load()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
async openFolder(host) {
|
||||||
|
await api.openSiteFolder(host)
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadCert(host, certType, certDataBase64) {
|
||||||
|
return await api.uploadCertificate(host, certType, certDataBase64)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
79
front_vue/src/Design/assets/css/base.css
Normal file
79
front_vue/src/Design/assets/css/base.css
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/* ============================================
|
||||||
|
Base Styles — сброс и общие стили
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--scrollbar-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--selection-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea, button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
112
front_vue/src/Design/assets/css/forms.css
Normal file
112
front_vue/src/Design/assets/css/forms.css
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/* ============================================
|
||||||
|
Общие стили форм
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.vaccess-page {
|
||||||
|
animation: fadeIn var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
padding: var(--space-xl);
|
||||||
|
background: rgba(var(--accent-rgb), 0.02);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.04);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-subsection-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 var(--space-lg) 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
border-bottom: 1px solid rgba(var(--accent-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-subsection-title i {
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row-3 {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status toggle buttons */
|
||||||
|
.status-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px var(--space-md);
|
||||||
|
background: rgba(var(--muted-rgb), 0.1);
|
||||||
|
border: 1px solid rgba(var(--muted-rgb), 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-btn:hover {
|
||||||
|
background: rgba(var(--muted-rgb), 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-btn.active {
|
||||||
|
background: rgba(var(--success-rgb), 0.2);
|
||||||
|
border-color: rgba(var(--success-rgb), 0.5);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-btn:last-child.active {
|
||||||
|
background: rgba(var(--danger-rgb), 0.2);
|
||||||
|
border-color: rgba(var(--danger-rgb), 0.5);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
168
front_vue/src/Design/assets/css/tables.css
Normal file
168
front_vue/src/Design/assets/css/tables.css
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/* ============================================
|
||||||
|
Общие стили таблиц
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
background: linear-gradient(180deg, var(--accent-purple), var(--accent-purple-light));
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background: var(--glass-bg-light);
|
||||||
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead {
|
||||||
|
background: var(--table-header-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead tr {
|
||||||
|
border-bottom: 1px solid var(--table-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
padding: 18px 20px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th:last-child {
|
||||||
|
width: 160px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--table-border);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: var(--table-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td:last-child {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-link {
|
||||||
|
color: var(--link-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-link:hover {
|
||||||
|
color: var(--link-hover-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-link i {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cert icons */
|
||||||
|
.cert-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-valid {
|
||||||
|
background: rgba(var(--success-rgb), 0.2);
|
||||||
|
color: var(--accent-green);
|
||||||
|
border: 1px solid rgba(var(--success-rgb), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-expired {
|
||||||
|
background: rgba(var(--danger-rgb), 0.2);
|
||||||
|
color: var(--accent-red);
|
||||||
|
border: 1px solid rgba(var(--danger-rgb), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-none {
|
||||||
|
background: rgba(var(--muted-rgb), 0.15);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid rgba(var(--muted-rgb), 0.3);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon buttons */
|
||||||
|
.icon-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--btn-icon-bg);
|
||||||
|
border: 1px solid var(--btn-icon-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--btn-icon-color);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--btn-icon-hover-bg);
|
||||||
|
border-color: var(--btn-icon-hover-border);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
98
front_vue/src/Design/assets/css/themes/dark.css
Normal file
98
front_vue/src/Design/assets/css/themes/dark.css
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/* ============================================
|
||||||
|
Тёмная тема (по умолчанию)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* Background */
|
||||||
|
--bg-primary: #0b101f;
|
||||||
|
--bg-secondary: #121420;
|
||||||
|
--bg-tertiary: #0d0f1c;
|
||||||
|
|
||||||
|
/* Glass Effect */
|
||||||
|
--glass-bg: rgba(20, 20, 40, 0.4);
|
||||||
|
--glass-bg-light: rgba(20, 20, 40, 0.3);
|
||||||
|
--glass-bg-dark: rgba(10, 14, 26, 0.5);
|
||||||
|
--glass-border: rgba(139, 92, 246, 0.15);
|
||||||
|
--glass-border-hover: rgba(139, 92, 246, 0.3);
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--accent-blue: #5b21b6;
|
||||||
|
--accent-blue-light: #7c3aed;
|
||||||
|
--accent-purple: #8b5cf6;
|
||||||
|
--accent-purple-light: #a78bfa;
|
||||||
|
--accent-cyan: #06b6d4;
|
||||||
|
--accent-green: #10b981;
|
||||||
|
--accent-red: #ef4444;
|
||||||
|
--accent-yellow: #f59e0b;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.7);
|
||||||
|
--shadow-purple: 0 8px 32px rgba(139, 92, 246, 0.3);
|
||||||
|
--shadow-green: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||||
|
--shadow-red: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--sidebar-bg: rgba(10, 14, 26, 0.95);
|
||||||
|
--sidebar-border: rgba(139, 92, 246, 0.15);
|
||||||
|
--nav-color: #94a3b8;
|
||||||
|
--nav-hover-bg: rgba(139, 92, 246, 0.1);
|
||||||
|
--nav-hover-color: #a78bfa;
|
||||||
|
--nav-active-bg: rgba(139, 92, 246, 0.15);
|
||||||
|
--nav-active-color: #a78bfa;
|
||||||
|
--nav-indicator: linear-gradient(180deg, #8b5cf6, #a78bfa);
|
||||||
|
|
||||||
|
/* Titlebar */
|
||||||
|
--titlebar-bg: rgba(10, 14, 26, 0.5);
|
||||||
|
--titlebar-border: rgba(139, 92, 246, 0.15);
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
--table-header-bg: rgba(139, 92, 246, 0.12);
|
||||||
|
--table-hover-bg: rgba(139, 92, 246, 0.08);
|
||||||
|
--table-border: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
/* Компоненты */
|
||||||
|
--card-hover-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
|
||||||
|
--card-border-hover: rgba(139, 92, 246, 0.3);
|
||||||
|
--service-card-gradient: linear-gradient(90deg, #8b5cf6, #a78bfa, #06b6d4);
|
||||||
|
--btn-icon-bg: rgba(139, 92, 246, 0.1);
|
||||||
|
--btn-icon-border: rgba(139, 92, 246, 0.3);
|
||||||
|
--btn-icon-color: #a78bfa;
|
||||||
|
--btn-icon-hover-bg: rgba(139, 92, 246, 0.25);
|
||||||
|
--btn-icon-hover-border: rgba(139, 92, 246, 0.5);
|
||||||
|
--btn-primary-bg: rgba(139, 92, 246, 0.15);
|
||||||
|
--btn-primary-border: rgba(139, 92, 246, 0.3);
|
||||||
|
--btn-primary-color: #a78bfa;
|
||||||
|
--btn-primary-hover-bg: rgba(139, 92, 246, 0.25);
|
||||||
|
--btn-primary-hover-border: rgba(139, 92, 246, 0.5);
|
||||||
|
--cert-tag-bg: rgba(139, 92, 246, 0.15);
|
||||||
|
--cert-tag-color: #a78bfa;
|
||||||
|
--link-color: #a78bfa;
|
||||||
|
--link-hover-color: #8b5cf6;
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
--scrollbar-bg: rgba(20, 20, 40, 0.3);
|
||||||
|
--scrollbar-thumb: rgba(139, 92, 246, 0.3);
|
||||||
|
|
||||||
|
/* RGB base — для alpha-композитинга: rgba(var(--x-rgb), 0.15) */
|
||||||
|
--accent-rgb: 139, 92, 246;
|
||||||
|
--success-rgb: 16, 185, 129;
|
||||||
|
--danger-rgb: 239, 68, 68;
|
||||||
|
--warning-rgb: 245, 158, 11;
|
||||||
|
--info-rgb: 6, 182, 212;
|
||||||
|
--muted-rgb: 100, 116, 139;
|
||||||
|
|
||||||
|
/* Utility */
|
||||||
|
--overlay-bg: rgba(0, 0, 0, 0.6);
|
||||||
|
--selection-bg: rgba(139, 92, 246, 0.3);
|
||||||
|
--input-focus-shadow: 0 0 0 2px rgba(139, 92, 246, 0.15);
|
||||||
|
--subtle-overlay: rgba(255, 255, 255, 0.02);
|
||||||
|
--divider-subtle: rgba(255, 255, 255, 0.1);
|
||||||
|
--warning-icon-color: rgba(251, 191, 36, 0.9);
|
||||||
|
}
|
||||||
98
front_vue/src/Design/assets/css/themes/light.css
Normal file
98
front_vue/src/Design/assets/css/themes/light.css
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/* ============================================
|
||||||
|
Светлая тема — мягкая, нейтральная палитра
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
/* Background */
|
||||||
|
--bg-primary: #f8fafc;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-tertiary: #f1f5f9;
|
||||||
|
|
||||||
|
/* Glass Effect — нейтральные серые */
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.8);
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.6);
|
||||||
|
--glass-bg-dark: rgba(248, 250, 252, 0.9);
|
||||||
|
--glass-border: rgba(226, 232, 240, 1);
|
||||||
|
--glass-border-hover: rgba(203, 213, 225, 1);
|
||||||
|
|
||||||
|
/* Accent — нейтральный slate */
|
||||||
|
--accent-blue: #334155;
|
||||||
|
--accent-blue-light: #475569;
|
||||||
|
--accent-purple: #334155;
|
||||||
|
--accent-purple-light: #475569;
|
||||||
|
--accent-cyan: #0891b2;
|
||||||
|
--accent-green: #16a34a;
|
||||||
|
--accent-red: #dc2626;
|
||||||
|
--accent-yellow: #d97706;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #1e293b;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
|
||||||
|
/* Shadows — мягкие без цвета */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-purple: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-green: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-red: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--sidebar-bg: #ffffff;
|
||||||
|
--sidebar-border: #e2e8f0;
|
||||||
|
--nav-color: #94a3b8;
|
||||||
|
--nav-hover-bg: #f1f5f9;
|
||||||
|
--nav-hover-color: #475569;
|
||||||
|
--nav-active-bg: #f1f5f9;
|
||||||
|
--nav-active-color: #1e293b;
|
||||||
|
--nav-indicator: linear-gradient(180deg, #475569, #64748b);
|
||||||
|
|
||||||
|
/* Titlebar */
|
||||||
|
--titlebar-bg: #ffffff;
|
||||||
|
--titlebar-border: #e2e8f0;
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
--table-header-bg: #f8fafc;
|
||||||
|
--table-hover-bg: #f8fafc;
|
||||||
|
--table-border: #f1f5f9;
|
||||||
|
|
||||||
|
/* Компоненты — нейтральные акценты */
|
||||||
|
--card-hover-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
--card-border-hover: #cbd5e1;
|
||||||
|
--service-card-gradient: linear-gradient(90deg, #64748b, #94a3b8, #cbd5e1);
|
||||||
|
--btn-icon-bg: transparent;
|
||||||
|
--btn-icon-border: transparent;
|
||||||
|
--btn-icon-color: #94a3b8;
|
||||||
|
--btn-icon-hover-bg: #e2e8f0;
|
||||||
|
--btn-icon-hover-border: #cbd5e1;
|
||||||
|
--btn-primary-bg: #475569;
|
||||||
|
--btn-primary-border: #475569;
|
||||||
|
--btn-primary-color: #ffffff;
|
||||||
|
--btn-primary-hover-bg: #334155;
|
||||||
|
--btn-primary-hover-border: #1e293b;
|
||||||
|
--cert-tag-bg: rgba(241, 245, 249, 1);
|
||||||
|
--cert-tag-color: #475569;
|
||||||
|
--link-color: #334155;
|
||||||
|
--link-hover-color: #1e293b;
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
--scrollbar-bg: #f1f5f9;
|
||||||
|
--scrollbar-thumb: #cbd5e1;
|
||||||
|
|
||||||
|
/* RGB base — для alpha-композитинга: rgba(var(--x-rgb), 0.15) */
|
||||||
|
--accent-rgb: 51, 65, 85;
|
||||||
|
--success-rgb: 22, 163, 74;
|
||||||
|
--danger-rgb: 220, 38, 38;
|
||||||
|
--warning-rgb: 217, 119, 6;
|
||||||
|
--info-rgb: 8, 145, 178;
|
||||||
|
--muted-rgb: 100, 116, 139;
|
||||||
|
|
||||||
|
/* Utility */
|
||||||
|
--overlay-bg: rgba(0, 0, 0, 0.5);
|
||||||
|
--selection-bg: rgba(51, 65, 85, 0.15);
|
||||||
|
--input-focus-shadow: 0 0 0 2px rgba(51, 65, 85, 0.12);
|
||||||
|
--subtle-overlay: rgba(0, 0, 0, 0.02);
|
||||||
|
--divider-subtle: rgba(0, 0, 0, 0.06);
|
||||||
|
--warning-icon-color: rgba(217, 119, 6, 0.9);
|
||||||
|
}
|
||||||
77
front_vue/src/Design/assets/css/transitions.css
Normal file
77
front_vue/src/Design/assets/css/transitions.css
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/* ============================================
|
||||||
|
Vue Transitions
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Fade */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide Up */
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: all var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide Right */
|
||||||
|
.slide-right-enter-active,
|
||||||
|
.slide-right-leave-active {
|
||||||
|
transition: all var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-right-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-right-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale */
|
||||||
|
.scale-enter-active,
|
||||||
|
.scale-leave-active {
|
||||||
|
transition: all var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-enter-from,
|
||||||
|
.scale-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification slide */
|
||||||
|
.notification-enter-active {
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-leave-active {
|
||||||
|
transition: all 0.2s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
62
front_vue/src/Design/assets/css/variables.css
Normal file
62
front_vue/src/Design/assets/css/variables.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/* ============================================
|
||||||
|
CSS Design Tokens — общие для всех тем
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Spacing */
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 32px;
|
||||||
|
--space-2xl: 48px;
|
||||||
|
--space-3xl: 60px;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
--radius-full: 50%;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-base: 0.2s ease;
|
||||||
|
--transition-slow: 0.3s ease;
|
||||||
|
--transition-bounce: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
--font-mono: 'Consolas', 'Courier New', monospace;
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--text-xs: 10px;
|
||||||
|
--text-sm: 11px;
|
||||||
|
--text-base: 13px;
|
||||||
|
--text-md: 14px;
|
||||||
|
--text-lg: 16px;
|
||||||
|
--text-xl: 18px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 28px;
|
||||||
|
|
||||||
|
/* Font Weights */
|
||||||
|
--font-normal: 400;
|
||||||
|
--font-medium: 500;
|
||||||
|
--font-semibold: 600;
|
||||||
|
--font-bold: 700;
|
||||||
|
|
||||||
|
/* Z-Index Scale */
|
||||||
|
--z-base: 1;
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-modal: 9998;
|
||||||
|
--z-notification: 9999;
|
||||||
|
--z-loader: 10000;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--header-height: 60px;
|
||||||
|
--sidebar-width: 80px;
|
||||||
|
|
||||||
|
/* Backdrop Filter */
|
||||||
|
--backdrop-blur: blur(20px) saturate(180%);
|
||||||
|
--backdrop-blur-light: blur(10px);
|
||||||
|
}
|
||||||
1
front_vue/src/Design/components/certs/CertCard.vue
Normal file
1
front_vue/src/Design/components/certs/CertCard.vue
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<script setup>`nconst { t } = useI18n()`ndefineProps({ cert: Object })`n</script>`n<template><div class="cert-card">{{ cert?.domain }}</div></template>
|
||||||
1
front_vue/src/Design/components/certs/CertGrid.vue
Normal file
1
front_vue/src/Design/components/certs/CertGrid.vue
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("certs.title") }}</div></template>
|
||||||
20
front_vue/src/Design/components/layout/AppFooter.vue
Normal file
20
front_vue/src/Design/components/layout/AppFooter.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="footer">
|
||||||
|
<p>{{ t('app.footer') }}</p>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.footer {
|
||||||
|
padding: var(--space-sm) var(--space-lg);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
background: var(--glass-bg-dark);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
front_vue/src/Design/components/layout/Breadcrumbs.vue
Normal file
87
front_vue/src/Design/components/layout/Breadcrumbs.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<div class="breadcrumbs-left">
|
||||||
|
<button class="breadcrumb-item" @click="goBack">
|
||||||
|
<i class="fas fa-arrow-left"></i> {{ t('common.back') }}
|
||||||
|
</button>
|
||||||
|
<template v-for="(item, index) in items" :key="index">
|
||||||
|
<span class="breadcrumb-separator">/</span>
|
||||||
|
<span class="breadcrumb-item" :class="{ active: index === items.length - 1 }">
|
||||||
|
{{ item }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.tabs" class="breadcrumbs-tabs">
|
||||||
|
<slot name="tabs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
padding: var(--space-md) 20px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.05);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-sm) var(--space-lg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.breadcrumb-item:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-separator {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
66
front_vue/src/Design/components/layout/PageHeader.vue
Normal file
66
front_vue/src/Design/components/layout/PageHeader.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
icon: { type: String, default: '' },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title-block">
|
||||||
|
<h2 class="page-title">
|
||||||
|
<i v-if="icon" :class="icon"></i>
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</h2>
|
||||||
|
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" class="page-actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
background: rgba(var(--accent-rgb), 0.03);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-block {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title i {
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
97
front_vue/src/Design/components/layout/Sidebar.vue
Normal file
97
front_vue/src/Design/components/layout/Sidebar.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ name: 'dashboard', icon: 'fas fa-home', route: '/' },
|
||||||
|
{ name: 'settings', icon: 'fas fa-cog', route: '/settings' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const isActive = (item) => {
|
||||||
|
if (item.route === '/') return route.path === '/'
|
||||||
|
return route.path.startsWith(item.route)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigate = (item) => {
|
||||||
|
router.push(item.route)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<button
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.name"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: isActive(item) }"
|
||||||
|
:title="t(`nav.${item.name}`)"
|
||||||
|
@click="navigate(item)"
|
||||||
|
>
|
||||||
|
<i :class="item.icon"></i>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border-right: 1px solid var(--sidebar-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: 0 var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: var(--nav-color);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 0;
|
||||||
|
background: var(--nav-indicator);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
transition: height var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--nav-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--nav-active-bg);
|
||||||
|
color: var(--nav-active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active::before {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
177
front_vue/src/Design/components/layout/TitleBar.vue
Normal file
177
front_vue/src/Design/components/layout/TitleBar.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const { isDark, toggleTheme } = useTheme()
|
||||||
|
|
||||||
|
const toggleLocale = () => {
|
||||||
|
const next = locale.value === 'ru' ? 'en' : 'ru'
|
||||||
|
locale.value = next
|
||||||
|
localStorage.setItem('vserver-locale', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverToggle = async () => {
|
||||||
|
if (appStore.serverRunning) {
|
||||||
|
appStore.setServerRunning(false)
|
||||||
|
} else {
|
||||||
|
appStore.setServerRunning(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="title-bar" style="--wails-draggable: drag">
|
||||||
|
<div class="title-bar-left">
|
||||||
|
<div class="app-logo">
|
||||||
|
<span class="logo-icon">🚀</span>
|
||||||
|
<span class="logo-text">{{ t('app.logo') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="server-status">
|
||||||
|
<span class="status-indicator" :class="appStore.serverRunning ? 'status-online' : 'status-offline'"></span>
|
||||||
|
<span class="status-text">{{ appStore.serverRunning ? t('server.running') : t('server.stopped') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="title-bar-right" style="--wails-draggable: no-drag">
|
||||||
|
<button class="server-control-btn" @click="serverToggle">
|
||||||
|
<i class="fas fa-power-off"></i>
|
||||||
|
<span class="btn-text">{{ appStore.serverRunning ? t('server.stop') : t('server.start') }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="locale-btn" @click="toggleLocale" :title="locale === 'ru' ? 'English' : 'Русский'">
|
||||||
|
{{ locale === 'ru' ? 'EN' : 'RU' }}
|
||||||
|
</button>
|
||||||
|
<button class="theme-btn" @click="toggleTheme" :title="isDark ? t('theme.light') : t('theme.dark')">
|
||||||
|
<i :class="isDark ? 'fas fa-sun' : 'fas fa-moon'"></i>
|
||||||
|
</button>
|
||||||
|
<button class="window-btn minimize-btn" :title="t('window.minimize')">
|
||||||
|
<i class="fas fa-window-minimize"></i>
|
||||||
|
</button>
|
||||||
|
<button class="window-btn maximize-btn" :title="t('window.maximize')">
|
||||||
|
<i class="far fa-window-maximize"></i>
|
||||||
|
</button>
|
||||||
|
<button class="window-btn close-btn" :title="t('window.close')">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.title-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 var(--space-md);
|
||||||
|
background: var(--titlebar-bg);
|
||||||
|
border-bottom: 1px solid var(--titlebar-border);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar-left,
|
||||||
|
.title-bar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon { font-size: var(--text-xl); }
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-online {
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 8px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-offline {
|
||||||
|
background: var(--accent-red);
|
||||||
|
box-shadow: 0 0 8px var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-control-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-control-btn:hover {
|
||||||
|
border-color: var(--glass-border-hover);
|
||||||
|
background: var(--glass-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn,
|
||||||
|
.window-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-btn:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.theme-btn:hover { color: var(--accent-yellow); }
|
||||||
|
.minimize-btn:hover { color: var(--accent-green); }
|
||||||
|
.maximize-btn:hover { color: var(--accent-cyan); }
|
||||||
|
.close-btn:hover { color: var(--accent-red); background: rgba(var(--danger-rgb), 0.15); }
|
||||||
|
</style>
|
||||||
94
front_vue/src/Design/components/proxies/ProxiesTable.vue
Normal file
94
front_vue/src/Design/components/proxies/ProxiesTable.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const proxiesStore = useProxiesStore()
|
||||||
|
const certsStore = useCertsStore()
|
||||||
|
|
||||||
|
const findCertForDomain = (domain) => {
|
||||||
|
const direct = certsStore.list.find(c => c.domain === domain && c.has_cert)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
const parts = domain.split('.')
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const wildcard = '*.' + parts.slice(1).join('.')
|
||||||
|
const wc = certsStore.list.find(c => c.domain === wildcard && c.has_cert)
|
||||||
|
if (wc) return wc
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cert of certsStore.list) {
|
||||||
|
if (cert.has_cert && cert.dns_names) {
|
||||||
|
for (const dns of cert.dns_names) {
|
||||||
|
if (dns === domain) return cert
|
||||||
|
if (dns.startsWith('*.')) {
|
||||||
|
const base = dns.slice(2)
|
||||||
|
const dParts = domain.split('.')
|
||||||
|
if (dParts.length >= 2 && dParts.slice(1).join('.') === base) return cert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">{{ t('proxies.title') }}</h2>
|
||||||
|
<VButton variant="primary" icon="fas fa-plus" @click="router.push('/proxies/create')">
|
||||||
|
{{ t('proxies.add') }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('proxies.externalDomain') }}</th>
|
||||||
|
<th>{{ t('proxies.localAddress') }}</th>
|
||||||
|
<th>{{ t('proxies.httpsCol') }}</th>
|
||||||
|
<th>{{ t('proxies.autoHttps') }}</th>
|
||||||
|
<th>{{ t('proxies.status') }}</th>
|
||||||
|
<th>{{ t('proxies.actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="proxy in proxiesStore.list" :key="proxy.ExternalDomain">
|
||||||
|
<td>
|
||||||
|
<span class="cert-icon" :class="findCertForDomain(proxy.ExternalDomain) ? (findCertForDomain(proxy.ExternalDomain).is_expired ? 'cert-expired' : 'cert-valid') : 'cert-none'" :title="findCertForDomain(proxy.ExternalDomain) ? (findCertForDomain(proxy.ExternalDomain).is_expired ? 'SSL истёк' : `SSL (${findCertForDomain(proxy.ExternalDomain).days_left} дн.)`) : 'Нет сертификата'">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</span>
|
||||||
|
<code class="clickable-link">{{ proxy.ExternalDomain }} <i class="fas fa-external-link-alt"></i></code>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ proxy.LocalAddress }}:{{ proxy.LocalPort }}</code></td>
|
||||||
|
<td>
|
||||||
|
<VBadge :variant="proxy.ServiceHTTPSuse ? 'yes' : 'no'">
|
||||||
|
{{ proxy.ServiceHTTPSuse ? 'HTTPS' : 'HTTP' }}
|
||||||
|
</VBadge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VBadge :variant="proxy.AutoHTTPS ? 'yes' : 'no'">
|
||||||
|
{{ proxy.AutoHTTPS ? t('common.yes') : t('common.no') }}
|
||||||
|
</VBadge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VBadge :variant="proxy.Enable ? 'online' : 'offline'">
|
||||||
|
{{ proxy.Enable ? 'active' : 'disabled' }}
|
||||||
|
</VBadge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="icon-btn" :title="t('sites.editVaccess')" @click="router.push(`/vaccess/${proxy.ExternalDomain}`)">
|
||||||
|
<i class="fas fa-user-lock"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" :title="t('certs.title')" @click="router.push(`/certs/${proxy.ExternalDomain}`)">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" :title="t('sites.edit')" @click="router.push(`/proxies/edit/${proxy.ExternalDomain}`)">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("common.edit") }}</div></template>
|
||||||
1
front_vue/src/Design/components/proxies/ProxyForm.vue
Normal file
1
front_vue/src/Design/components/proxies/ProxyForm.vue
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("common.edit") }}</div></template>
|
||||||
128
front_vue/src/Design/components/services/ServiceCard.vue
Normal file
128
front_vue/src/Design/components/services/ServiceCard.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
service: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const serviceIcons = {
|
||||||
|
HTTP: 'fas fa-globe',
|
||||||
|
HTTPS: 'fas fa-lock',
|
||||||
|
MySQL: 'fas fa-database',
|
||||||
|
PHP: 'fab fa-php',
|
||||||
|
Proxy: 'fas fa-exchange-alt',
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceInfoLabel = {
|
||||||
|
HTTP: 'services.port',
|
||||||
|
HTTPS: 'services.port',
|
||||||
|
MySQL: 'services.port',
|
||||||
|
PHP: 'services.ports',
|
||||||
|
Proxy: 'services.rules',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="service-card">
|
||||||
|
<div class="service-header">
|
||||||
|
<h3 class="service-name">
|
||||||
|
<i :class="serviceIcons[service.name] || 'fas fa-server'"></i>
|
||||||
|
{{ service.name }}
|
||||||
|
</h3>
|
||||||
|
<VBadge :variant="service.status ? 'online' : 'offline'">
|
||||||
|
{{ service.status ? t('common.enabled') : t('common.disabled') }}
|
||||||
|
</VBadge>
|
||||||
|
</div>
|
||||||
|
<div class="service-info">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">{{ t(serviceInfoLabel[service.name] || 'services.port') }}:</span>
|
||||||
|
<span class="info-value">{{ service.port || service.info || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="service.info && service.port" class="info-row">
|
||||||
|
<span class="info-label">{{ t('services.rules') }}:</span>
|
||||||
|
<span class="info-value">{{ service.info }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.service-card {
|
||||||
|
background: var(--glass-bg-light);
|
||||||
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
transition: all var(--transition-bounce);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--service-card-gradient);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--card-hover-shadow);
|
||||||
|
border-color: var(--card-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-name {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-name i {
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
front_vue/src/Design/components/services/ServicesGrid.vue
Normal file
36
front_vue/src/Design/components/services/ServicesGrid.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="section">
|
||||||
|
<div class="services-grid">
|
||||||
|
<ServiceCard
|
||||||
|
v-for="service in servicesStore.list"
|
||||||
|
:key="service.name"
|
||||||
|
:service="service"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.services-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.services-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.services-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.services-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
front_vue/src/Design/components/services/test.txt
Normal file
1
front_vue/src/Design/components/services/test.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
test
|
||||||
1
front_vue/src/Design/components/sites/SiteEditModal.vue
Normal file
1
front_vue/src/Design/components/sites/SiteEditModal.vue
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("common.edit") }}</div></template>
|
||||||
1
front_vue/src/Design/components/sites/SiteForm.vue
Normal file
1
front_vue/src/Design/components/sites/SiteForm.vue
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("common.edit") }}</div></template>
|
||||||
110
front_vue/src/Design/components/sites/SitesTable.vue
Normal file
110
front_vue/src/Design/components/sites/SitesTable.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const sitesStore = useSitesStore()
|
||||||
|
const certsStore = useCertsStore()
|
||||||
|
const { success, error } = useNotification()
|
||||||
|
const modal = useModal()
|
||||||
|
|
||||||
|
const findCertForDomain = (domain, aliases = []) => {
|
||||||
|
const allDomains = [domain, ...aliases.filter(a => !a.includes('*'))]
|
||||||
|
for (const d of allDomains) {
|
||||||
|
const direct = certsStore.list.find(c => c.domain === d && c.has_cert)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
const parts = d.split('.')
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const wildcard = '*.' + parts.slice(1).join('.')
|
||||||
|
const wc = certsStore.list.find(c => c.domain === wildcard && c.has_cert)
|
||||||
|
if (wc) return wc
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cert of certsStore.list) {
|
||||||
|
if (cert.has_cert && cert.dns_names) {
|
||||||
|
for (const dns of cert.dns_names) {
|
||||||
|
if (dns === d) return cert
|
||||||
|
if (dns.startsWith('*.')) {
|
||||||
|
const base = dns.slice(2)
|
||||||
|
const dParts = d.split('.')
|
||||||
|
if (dParts.length >= 2 && dParts.slice(1).join('.') === base) return cert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (site) => {
|
||||||
|
modal.open({
|
||||||
|
title: t('sites.deleteTitle'),
|
||||||
|
message: t('sites.deleteConfirm', { name: site.name, host: site.host }),
|
||||||
|
warning: t('sites.deleteWarning'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const result = await sitesStore.remove(site.host)
|
||||||
|
if (result === 'OK') success(t('notify.siteDeleted'))
|
||||||
|
else error(String(result))
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">{{ t('sites.title') }}</h2>
|
||||||
|
<VButton variant="primary" icon="fas fa-plus" @click="router.push('/sites/create')">
|
||||||
|
{{ t('sites.add') }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('sites.name') }}</th>
|
||||||
|
<th>{{ t('sites.host') }}</th>
|
||||||
|
<th>{{ t('sites.alias') }}</th>
|
||||||
|
<th>{{ t('sites.status') }}</th>
|
||||||
|
<th>{{ t('sites.rootFile') }}</th>
|
||||||
|
<th>{{ t('sites.actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="site in sitesStore.list" :key="site.host">
|
||||||
|
<td>
|
||||||
|
<span class="cert-icon" :class="findCertForDomain(site.host, site.alias) ? (findCertForDomain(site.host, site.alias).is_expired ? 'cert-expired' : 'cert-valid') : 'cert-none'" :title="findCertForDomain(site.host, site.alias) ? (findCertForDomain(site.host, site.alias).is_expired ? 'SSL истёк' : `SSL (${findCertForDomain(site.host, site.alias).days_left} дн.)`) : 'Нет сертификата'">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</span>
|
||||||
|
{{ site.name }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code class="clickable-link">{{ site.host }} <i class="fas fa-external-link-alt"></i></code>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ site.alias?.join(', ') || '—' }}</code></td>
|
||||||
|
<td>
|
||||||
|
<VBadge :variant="site.status === 'active' ? 'online' : 'offline'">
|
||||||
|
{{ site.status }}
|
||||||
|
</VBadge>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ site.root_file }}</code></td>
|
||||||
|
<td>
|
||||||
|
<button class="icon-btn" :title="t('sites.openFolder')" @click="sitesStore.openFolder(site.host)">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" :title="t('sites.editVaccess')" @click="router.push(`/vaccess/${site.host}`)">
|
||||||
|
<i class="fas fa-user-lock"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" :title="t('certs.title')" @click="router.push(`/certs/${site.host}`)">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" :title="t('sites.edit')" @click="router.push(`/sites/edit/${site.host}`)">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
59
front_vue/src/Design/components/ui/SslUploadSection.vue
Normal file
59
front_vue/src/Design/components/ui/SslUploadSection.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: 'none' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'cert-file', 'key-file', 'ca-file'])
|
||||||
|
|
||||||
|
const certModeOptions = computed(() => [
|
||||||
|
{ value: 'none', label: t('certs.modeNone') },
|
||||||
|
{ value: 'auto', label: t('certs.modeAuto') },
|
||||||
|
{ value: 'upload', label: t('certs.modeUpload') },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ssl-section">
|
||||||
|
<h3 class="form-subsection-title">
|
||||||
|
<i class="fas fa-lock"></i> {{ t('common.sslCerts') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<VSelect
|
||||||
|
:label="t('certs.mode')"
|
||||||
|
:model-value="modelValue"
|
||||||
|
:options="certModeOptions"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="modelValue === 'upload'">
|
||||||
|
<VFileUpload
|
||||||
|
:label="t('certs.certFile')"
|
||||||
|
accept=".crt,.pem"
|
||||||
|
required
|
||||||
|
@select="emit('cert-file', $event)"
|
||||||
|
/>
|
||||||
|
<VFileUpload
|
||||||
|
:label="t('certs.keyFile')"
|
||||||
|
accept=".key,.pem"
|
||||||
|
required
|
||||||
|
@select="emit('key-file', $event)"
|
||||||
|
/>
|
||||||
|
<VFileUpload
|
||||||
|
:label="t('certs.caFile')"
|
||||||
|
accept=".crt,.pem"
|
||||||
|
:hint="t('certs.caHint')"
|
||||||
|
@select="emit('ca-file', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ssl-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
front_vue/src/Design/components/ui/VBadge.vue
Normal file
32
front_vue/src/Design/components/ui/VBadge.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
variant: { type: String, default: 'default' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="v-badge" :class="`v-badge--${variant}`">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-badge--online { background: linear-gradient(135deg, rgba(var(--success-rgb), 0.25), rgba(var(--success-rgb), 0.15)); color: var(--accent-green); border-color: rgba(var(--success-rgb), 0.4); }
|
||||||
|
.v-badge--offline { background: linear-gradient(135deg, rgba(var(--danger-rgb), 0.25), rgba(var(--danger-rgb), 0.15)); color: var(--accent-red); border-color: rgba(var(--danger-rgb), 0.4); }
|
||||||
|
.v-badge--pending { background: linear-gradient(135deg, rgba(var(--warning-rgb), 0.25), rgba(var(--warning-rgb), 0.15)); color: var(--accent-yellow); border-color: rgba(var(--warning-rgb), 0.4); }
|
||||||
|
.v-badge--yes { background: linear-gradient(135deg, rgba(var(--success-rgb), 0.25), rgba(var(--success-rgb), 0.15)); color: var(--accent-green); border-color: rgba(var(--success-rgb), 0.4); }
|
||||||
|
.v-badge--no { background: linear-gradient(135deg, rgba(var(--muted-rgb), 0.2), rgba(var(--muted-rgb), 0.1)); color: var(--text-muted); border-color: rgba(var(--muted-rgb), 0.3); }
|
||||||
|
.v-badge--default { background: var(--glass-bg); color: var(--text-secondary); border-color: var(--glass-border); }
|
||||||
|
</style>
|
||||||
96
front_vue/src/Design/components/ui/VButton.vue
Normal file
96
front_vue/src/Design/components/ui/VButton.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
variant: { type: String, default: 'default' },
|
||||||
|
icon: { type: String, default: '' },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['click'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="v-btn"
|
||||||
|
:class="[`v-btn--${variant}`, { 'v-btn--loading': loading }]"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
@click="$emit('click', $event)"
|
||||||
|
>
|
||||||
|
<i v-if="loading" class="fas fa-spinner fa-spin"></i>
|
||||||
|
<i v-else-if="icon" :class="icon"></i>
|
||||||
|
<span v-if="$slots.default"><slot /></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--glass-border-hover);
|
||||||
|
background: var(--glass-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--primary {
|
||||||
|
background: var(--btn-primary-bg);
|
||||||
|
border-color: var(--btn-primary-border);
|
||||||
|
color: var(--btn-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--primary:hover:not(:disabled) {
|
||||||
|
background: var(--btn-primary-hover-bg);
|
||||||
|
border-color: var(--btn-primary-hover-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--danger {
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--danger:hover:not(:disabled) {
|
||||||
|
background: rgba(var(--danger-rgb), 0.15);
|
||||||
|
border-color: rgba(var(--danger-rgb), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--success {
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--success:hover:not(:disabled) {
|
||||||
|
background: rgba(var(--success-rgb), 0.15);
|
||||||
|
border-color: rgba(var(--success-rgb), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--icon {
|
||||||
|
padding: var(--space-xs);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--icon:hover:not(:disabled) {
|
||||||
|
background: var(--btn-icon-bg);
|
||||||
|
color: var(--btn-icon-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
front_vue/src/Design/components/ui/VCard.vue
Normal file
63
front_vue/src/Design/components/ui/VCard.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
icon: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="v-card">
|
||||||
|
<div v-if="title || $slots.header" class="v-card-header">
|
||||||
|
<h3 v-if="title" class="v-card-title">
|
||||||
|
<i v-if="icon" :class="icon"></i> {{ title }}
|
||||||
|
</h3>
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
<div class="v-card-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.footer" class="v-card-footer">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card:hover {
|
||||||
|
border-color: var(--glass-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card-body {
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card-footer {
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
86
front_vue/src/Design/components/ui/VFileUpload.vue
Normal file
86
front_vue/src/Design/components/ui/VFileUpload.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
accept: { type: String, default: '' },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
hint: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select'])
|
||||||
|
const fileName = ref('')
|
||||||
|
|
||||||
|
const onFileSelect = (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
fileName.value = file.name
|
||||||
|
emit('select', file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="v-file-group">
|
||||||
|
<label v-if="label" class="v-file-label">
|
||||||
|
{{ label }} <span v-if="required" class="v-file-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="v-file-wrapper">
|
||||||
|
<input type="file" class="v-file-input" :accept="accept" @change="onFileSelect">
|
||||||
|
<div class="v-file-btn">
|
||||||
|
<i class="fas fa-file-upload"></i>
|
||||||
|
<span>{{ fileName || $t('certs.selectFile') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small v-if="hint" class="v-file-hint">
|
||||||
|
<i class="fas fa-info-circle"></i> {{ hint }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-file-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-file-label {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-file-required { color: var(--accent-red); }
|
||||||
|
|
||||||
|
.v-file-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-file-input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-file-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px dashed var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-file-wrapper:hover .v-file-btn {
|
||||||
|
border-color: var(--accent-purple);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-file-hint {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
81
front_vue/src/Design/components/ui/VInput.vue
Normal file
81
front_vue/src/Design/components/ui/VInput.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
modelValue: { type: [String, Number], default: '' },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
placeholder: { type: String, default: '' },
|
||||||
|
hint: { type: String, default: '' },
|
||||||
|
type: { type: String, default: 'text' },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="v-input-group">
|
||||||
|
<div v-if="label || hint" class="v-input-header">
|
||||||
|
<VTooltip v-if="hint" :text="hint" />
|
||||||
|
<label v-if="label" class="v-input-label">
|
||||||
|
{{ label }} <span v-if="required" class="v-input-required">*</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="v-input"
|
||||||
|
:type="type"
|
||||||
|
:value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="$emit('update:modelValue', $event.target.value)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-input-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-input-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-input-required { color: var(--accent-red); }
|
||||||
|
|
||||||
|
.v-input {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--glass-bg-dark);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.v-input:focus {
|
||||||
|
border-color: var(--accent-purple);
|
||||||
|
box-shadow: var(--input-focus-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
58
front_vue/src/Design/components/ui/VLoader.vue
Normal file
58
front_vue/src/Design/components/ui/VLoader.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
text: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="v-loader">
|
||||||
|
<div class="v-loader-content">
|
||||||
|
<div class="v-loader-icon">🚀</div>
|
||||||
|
<div class="v-loader-text">{{ text || t('app.loading') }}</div>
|
||||||
|
<div class="v-loader-spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-loader {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: var(--z-loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-loader-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-loader-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-loader-text {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-loader-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 3px solid var(--glass-border);
|
||||||
|
border-top-color: var(--accent-purple);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||||
|
</style>
|
||||||
111
front_vue/src/Design/components/ui/VModal.vue
Normal file
111
front_vue/src/Design/components/ui/VModal.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const modal = useModal()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="modal.isOpen.value" class="v-modal-overlay" @click.self="modal.close()">
|
||||||
|
<transition name="scale">
|
||||||
|
<div v-if="modal.isOpen.value" class="v-modal-window">
|
||||||
|
<div class="v-modal-header">
|
||||||
|
<h3 class="v-modal-title">{{ modal.title.value }}</h3>
|
||||||
|
<button class="v-modal-close" @click="modal.close()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="v-modal-content">
|
||||||
|
<component v-if="modal.content.value" :is="modal.content.value" />
|
||||||
|
</div>
|
||||||
|
<div class="v-modal-footer">
|
||||||
|
<VButton v-if="modal.hasDelete.value" variant="danger" icon="fas fa-trash" @click="modal.remove()">
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</VButton>
|
||||||
|
<VButton @click="modal.close()">{{ t('common.cancel') }}</VButton>
|
||||||
|
<VButton variant="primary" icon="fas fa-save" @click="modal.save()">
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--overlay-bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-modal-window {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-modal-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-modal-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-modal-close:hover {
|
||||||
|
color: var(--accent-red);
|
||||||
|
background: rgba(var(--danger-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-modal-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-modal-footer .v-btn--danger {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
69
front_vue/src/Design/components/ui/VNotification.vue
Normal file
69
front_vue/src/Design/components/ui/VNotification.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup>
|
||||||
|
const { notifications, remove } = useNotification()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="v-notifications">
|
||||||
|
<TransitionGroup name="notification">
|
||||||
|
<div
|
||||||
|
v-for="n in notifications"
|
||||||
|
:key="n.id"
|
||||||
|
class="v-notify"
|
||||||
|
:class="`v-notify--${n.type}`"
|
||||||
|
@click="remove(n.id)"
|
||||||
|
>
|
||||||
|
<i :class="{
|
||||||
|
'fas fa-check-circle': n.type === 'success',
|
||||||
|
'fas fa-exclamation-circle': n.type === 'error',
|
||||||
|
'fas fa-info-circle': n.type === 'info',
|
||||||
|
}"></i>
|
||||||
|
<span>{{ n.message }}</span>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-notifications {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--header-height) + var(--space-md));
|
||||||
|
right: var(--space-md);
|
||||||
|
z-index: var(--z-notification);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-notify {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-notify--success {
|
||||||
|
background: rgba(var(--success-rgb), 0.15);
|
||||||
|
color: var(--accent-green);
|
||||||
|
border-color: rgba(var(--success-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-notify--error {
|
||||||
|
background: rgba(var(--danger-rgb), 0.15);
|
||||||
|
color: var(--accent-red);
|
||||||
|
border-color: rgba(var(--danger-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-notify--info {
|
||||||
|
background: rgba(var(--info-rgb), 0.15);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
border-color: rgba(var(--info-rgb), 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
173
front_vue/src/Design/components/ui/VSelect.vue
Normal file
173
front_vue/src/Design/components/ui/VSelect.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: [String, Number], default: '' },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const selectRef = ref(null)
|
||||||
|
|
||||||
|
const selectedLabel = computed(() => {
|
||||||
|
const opt = props.options.find(o => o.value === props.modelValue)
|
||||||
|
return opt ? opt.label : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const select = (value) => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDocClick = (e) => {
|
||||||
|
if (selectRef.value && !selectRef.value.contains(e.target)) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('click', onDocClick))
|
||||||
|
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="v-select-group">
|
||||||
|
<label v-if="label" class="v-select-label">
|
||||||
|
{{ label }} <span v-if="required" class="v-select-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div ref="selectRef" class="v-select" :class="{ open: isOpen }">
|
||||||
|
<div class="v-select-trigger" @click="toggle">
|
||||||
|
<span class="v-select-value">{{ selectedLabel }}</span>
|
||||||
|
<i class="fas fa-chevron-down v-select-arrow"></i>
|
||||||
|
</div>
|
||||||
|
<transition name="dropdown">
|
||||||
|
<div v-if="isOpen" class="v-select-dropdown">
|
||||||
|
<div
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt.value"
|
||||||
|
class="v-select-option"
|
||||||
|
:class="{ selected: opt.value === modelValue }"
|
||||||
|
@click="select(opt.value)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-select-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-required { color: var(--accent-red); }
|
||||||
|
|
||||||
|
.v-select {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-trigger {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--glass-bg-dark);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-trigger:hover {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select.open .v-select-trigger {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.6);
|
||||||
|
box-shadow: var(--input-focus-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-value {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-arrow {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select.open .v-select-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-option {
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-option:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-option.selected {
|
||||||
|
background: rgba(var(--accent-rgb), 0.2);
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select-option.selected::before {
|
||||||
|
content: '✓ ';
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-enter-active,
|
||||||
|
.dropdown-leave-active {
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-enter-from,
|
||||||
|
.dropdown-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
78
front_vue/src/Design/components/ui/VTable.vue
Normal file
78
front_vue/src/Design/components/ui/VTable.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
columns: { type: Array, default: () => [] },
|
||||||
|
data: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="v-table-container">
|
||||||
|
<table class="v-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="col in columns" :key="col.key">{{ col.label }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in data" :key="index">
|
||||||
|
<slot name="row" :row="row" :index="index" />
|
||||||
|
</tr>
|
||||||
|
<tr v-if="data.length === 0">
|
||||||
|
<td :colspan="columns.length" class="v-table-empty">
|
||||||
|
<slot name="empty">{{ $t('common.loading') }}</slot>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table th {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background: var(--glass-bg-dark);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table td {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table tbody tr {
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table tbody tr:hover {
|
||||||
|
background: var(--glass-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-xl) !important;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
81
front_vue/src/Design/components/ui/VToggle.vue
Normal file
81
front_vue/src/Design/components/ui/VToggle.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
emit('update:modelValue', !props.modelValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="v-toggle-wrapper" @click="toggle">
|
||||||
|
<div class="v-toggle">
|
||||||
|
<input type="checkbox" :checked="modelValue">
|
||||||
|
<span class="v-toggle-slider"></span>
|
||||||
|
</div>
|
||||||
|
<span v-if="label" class="v-toggle-label">{{ label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-toggle-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toggle input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 11px;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toggle-slider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
left: 2px;
|
||||||
|
top: 2px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toggle input:checked + .v-toggle-slider {
|
||||||
|
background: rgba(var(--accent-rgb), 0.3);
|
||||||
|
border-color: var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toggle input:checked + .v-toggle-slider::before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
background: var(--accent-purple-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toggle-label {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
106
front_vue/src/Design/components/ui/VTooltip.vue
Normal file
106
front_vue/src/Design/components/ui/VTooltip.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
text: { type: String, default: '' },
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="v-tooltip-wrap" @mouseenter="show = true" @mouseleave="show = false">
|
||||||
|
<span class="v-tooltip-trigger">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
</span>
|
||||||
|
<transition name="tooltip">
|
||||||
|
<div v-if="show" class="v-tooltip-popup">
|
||||||
|
<p v-if="text">{{ text }}</p>
|
||||||
|
<div v-if="items.length" class="v-tooltip-list">
|
||||||
|
<div v-for="(item, i) in items" :key="i" class="v-tooltip-item">{{ item }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-tooltip-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-tooltip-trigger {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: help;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-tooltip-trigger:hover {
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-tooltip-popup {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 1000;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-tooltip-popup::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4px;
|
||||||
|
left: 8px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||||
|
border-right: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-tooltip-popup p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-tooltip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-tooltip-item {
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid rgba(var(--accent-rgb), 0.05);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-tooltip-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-enter-active,
|
||||||
|
.tooltip-leave-active {
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-enter-from,
|
||||||
|
.tooltip-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
front_vue/src/Design/components/vaccess/FieldEditor.vue
Normal file
1
front_vue/src/Design/components/vaccess/FieldEditor.vue
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>Field Editor</div></template>
|
||||||
1
front_vue/src/Design/components/vaccess/VAccessHelp.vue
Normal file
1
front_vue/src/Design/components/vaccess/VAccessHelp.vue
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("vaccess.helpTab") }}</div></template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>Rule Row</div></template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("vaccess.title") }}</div></template>
|
||||||
58
front_vue/src/Design/layouts/MainLayout.vue
Normal file
58
front_vue/src/Design/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup>
|
||||||
|
const { theme, isDark, toggleTheme, initTheme } = useTheme()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initTheme()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-layout">
|
||||||
|
<TitleBar />
|
||||||
|
<div class="app-body">
|
||||||
|
<Sidebar />
|
||||||
|
<div class="app-content">
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</main>
|
||||||
|
<AppFooter />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VNotification />
|
||||||
|
<VModal />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
234
front_vue/src/Design/views/CertManagerView.vue
Normal file
234
front_vue/src/Design/views/CertManagerView.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const certsStore = useCertsStore()
|
||||||
|
const { success, error } = useNotification()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
host: { type: String, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const certs = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await certsStore.loadAll()
|
||||||
|
certs.value = certsStore.list.filter(c =>
|
||||||
|
c.dns_names?.some(d => d === props.host || d === `*.${props.host}` || props.host.match(new RegExp(d.replace('*.', '.*\\.'))))
|
||||||
|
)
|
||||||
|
if (certs.value.length === 0) {
|
||||||
|
const info = await certsStore.getInfo(props.host)
|
||||||
|
if (info && info.has_cert) certs.value = [info]
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const issueCert = async (domain) => {
|
||||||
|
const result = await certsStore.issue(domain)
|
||||||
|
if (result === 'OK') success(t('notify.certIssued'))
|
||||||
|
else error(String(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
const renewCert = async (domain) => {
|
||||||
|
const result = await certsStore.renew(domain)
|
||||||
|
if (result === 'OK') success(t('notify.certRenewed'))
|
||||||
|
else error(String(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCert = async (domain) => {
|
||||||
|
const result = await certsStore.remove(domain)
|
||||||
|
if (result === 'OK') success(t('notify.certDeleted'))
|
||||||
|
else error(String(result))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="vaccess-page">
|
||||||
|
<Breadcrumbs :items="[host, t('certs.title')]" />
|
||||||
|
|
||||||
|
<PageHeader icon="fas fa-shield-alt" :title="t('certs.title')" :subtitle="`${t('certs.subtitle')} — ${host}`" />
|
||||||
|
|
||||||
|
<div class="cert-manager-content">
|
||||||
|
<!-- Нет сертификатов -->
|
||||||
|
<div v-if="!loading && certs.length === 0" class="cert-empty">
|
||||||
|
<i class="fas fa-certificate"></i>
|
||||||
|
<h3>{{ t('certs.noCert') }}</h3>
|
||||||
|
<p>{{ t('certs.subtitle') }}</p>
|
||||||
|
<VButton icon="fas fa-plus" @click="issueCert(host)">{{ t('certs.issue') }}</VButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Карточки сертификатов -->
|
||||||
|
<div v-for="cert in certs" :key="cert.domain" class="cert-card" :class="{ 'cert-card-empty': !cert.has_cert }">
|
||||||
|
<div class="cert-card-header">
|
||||||
|
<div class="cert-card-title" :class="{ expired: cert.is_expired }">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
<h3>{{ cert.domain }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="cert-card-actions">
|
||||||
|
<VButton v-if="cert.has_cert && !cert.is_expired" variant="success" icon="fas fa-sync" @click="renewCert(cert.domain)">
|
||||||
|
{{ t('certs.renew') }}
|
||||||
|
</VButton>
|
||||||
|
<VButton v-if="!cert.has_cert || cert.is_expired" icon="fas fa-plus" @click="issueCert(cert.domain)">
|
||||||
|
{{ t('certs.issue') }}
|
||||||
|
</VButton>
|
||||||
|
<VButton v-if="cert.has_cert" variant="danger" icon="fas fa-trash" @click="deleteCert(cert.domain)">
|
||||||
|
{{ t('certs.delete') }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cert.has_cert" class="cert-info-grid">
|
||||||
|
<div class="cert-info-item">
|
||||||
|
<div class="cert-info-label">{{ t('certs.status') }}</div>
|
||||||
|
<div class="cert-info-value" :class="cert.is_expired ? 'expired' : 'valid'">
|
||||||
|
{{ cert.is_expired ? t('certs.expired') : `${t('certs.active')} (${t('certs.daysLeft', { n: cert.days_left })})` }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cert-info-item">
|
||||||
|
<div class="cert-info-label">{{ t('certs.issuer') }}</div>
|
||||||
|
<div class="cert-info-value">{{ cert.issuer }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cert-info-item">
|
||||||
|
<div class="cert-info-label">{{ t('certs.issued') }}</div>
|
||||||
|
<div class="cert-info-value">{{ cert.not_before }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cert-info-item">
|
||||||
|
<div class="cert-info-label">{{ t('certs.expires') }}</div>
|
||||||
|
<div class="cert-info-value" :class="cert.is_expired ? 'expired' : ''">{{ cert.not_after }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cert.dns_names?.length" class="cert-domains-list">
|
||||||
|
<span v-for="dns in cert.dns_names" :key="dns" class="cert-domain-tag">{{ dns }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cert-manager-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-card {
|
||||||
|
background: rgba(var(--accent-rgb), 0.03);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-card-empty {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
padding-bottom: var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-card-title i {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-card-title.expired i {
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-card-title h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-info-item {
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--subtle-overlay);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-info-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-info-value {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-info-value.valid {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-info-value.expired {
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-domains-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-domain-tag {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 40px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-empty i {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-empty h3 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-empty p {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
front_vue/src/Design/views/DashboardView.vue
Normal file
31
front_vue/src/Design/views/DashboardView.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup>
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
const sitesStore = useSitesStore()
|
||||||
|
const proxiesStore = useProxiesStore()
|
||||||
|
const certsStore = useCertsStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
servicesStore.load(),
|
||||||
|
sitesStore.load(),
|
||||||
|
proxiesStore.load(),
|
||||||
|
certsStore.loadAll(),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dashboard-view">
|
||||||
|
<ServicesGrid />
|
||||||
|
<SitesTable />
|
||||||
|
<ProxiesTable />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
72
front_vue/src/Design/views/ProxyCreateView.vue
Normal file
72
front_vue/src/Design/views/ProxyCreateView.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const proxiesStore = useProxiesStore()
|
||||||
|
const { success, error } = useNotification()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
domain: '',
|
||||||
|
localAddr: '127.0.0.1',
|
||||||
|
localPort: '',
|
||||||
|
serviceHttps: false,
|
||||||
|
autoHttps: true,
|
||||||
|
certMode: 'none',
|
||||||
|
})
|
||||||
|
|
||||||
|
const creating = ref(false)
|
||||||
|
|
||||||
|
const createProxy = async () => {
|
||||||
|
if (!form.domain || !form.localPort) return
|
||||||
|
creating.value = true
|
||||||
|
const result = await proxiesStore.load()
|
||||||
|
creating.value = false
|
||||||
|
success(t('notify.dataSaved'))
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="vaccess-page">
|
||||||
|
<Breadcrumbs :items="[t('proxies.create')]" />
|
||||||
|
|
||||||
|
<PageHeader icon="fas fa-plus-circle" :title="t('proxies.create')" :subtitle="t('proxies.createDesc')">
|
||||||
|
<template #actions>
|
||||||
|
<VButton variant="success" icon="fas fa-check" :loading="creating" @click="createProxy">
|
||||||
|
{{ t('proxies.createBtn') }}
|
||||||
|
</VButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="settings-form">
|
||||||
|
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> {{ t('common.basicInfo') }}</h3>
|
||||||
|
|
||||||
|
<VInput v-model="form.domain" :label="t('proxies.formDomain')" :placeholder="t('proxies.formDomainPlaceholder')" :hint="t('proxies.formDomainHint')" required />
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<VInput v-model="form.localAddr" :label="t('proxies.formLocalAddr')" placeholder="127.0.0.1" required />
|
||||||
|
<VInput v-model="form.localPort" :label="t('proxies.formLocalPort')" placeholder="3000" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-label-row">
|
||||||
|
<VTooltip :text="t('proxies.formServiceHttpsHint')" />
|
||||||
|
<label class="form-label">{{ t('proxies.formServiceHttps') }}</label>
|
||||||
|
</div>
|
||||||
|
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-label-row">
|
||||||
|
<VTooltip :text="t('proxies.formAutoHttpsHint')" />
|
||||||
|
<label class="form-label">{{ t('proxies.formAutoHttps') }}</label>
|
||||||
|
</div>
|
||||||
|
<VToggle v-model="form.autoHttps" :label="t('common.enabled')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SslUploadSection v-model="form.certMode" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
109
front_vue/src/Design/views/ProxyEditView.vue
Normal file
109
front_vue/src/Design/views/ProxyEditView.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const proxiesStore = useProxiesStore()
|
||||||
|
const { success, error } = useNotification()
|
||||||
|
const modal = useModal()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
domain: { type: String, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
domain: '',
|
||||||
|
localAddr: '127.0.0.1',
|
||||||
|
localPort: '',
|
||||||
|
enabled: true,
|
||||||
|
serviceHttps: false,
|
||||||
|
autoHttps: true,
|
||||||
|
autoSSL: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!proxiesStore.loaded) await proxiesStore.load()
|
||||||
|
const proxy = proxiesStore.list.find(p => p.ExternalDomain === props.domain)
|
||||||
|
if (proxy) {
|
||||||
|
form.domain = proxy.ExternalDomain
|
||||||
|
form.localAddr = proxy.LocalAddress
|
||||||
|
form.localPort = proxy.LocalPort
|
||||||
|
form.enabled = proxy.Enable
|
||||||
|
form.serviceHttps = proxy.ServiceHTTPSuse
|
||||||
|
form.autoHttps = proxy.AutoHTTPS
|
||||||
|
form.autoSSL = proxy.AutoCreateSSL || false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveProxy = async () => {
|
||||||
|
saving.value = true
|
||||||
|
success(t('notify.dataSaved'))
|
||||||
|
saving.value = false
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
modal.open({
|
||||||
|
title: t('proxies.deleteTitle'),
|
||||||
|
message: t('proxies.deleteConfirm', { domain: form.domain }),
|
||||||
|
onConfirm: async () => {
|
||||||
|
success(t('notify.proxyDeleted'))
|
||||||
|
modal.close()
|
||||||
|
router.push('/')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="vaccess-page">
|
||||||
|
<Breadcrumbs :items="[domain]" />
|
||||||
|
|
||||||
|
<PageHeader icon="fas fa-edit" :title="`${t('sites.edit')} — ${domain}`">
|
||||||
|
<template #actions>
|
||||||
|
<VButton variant="danger" icon="fas fa-trash" @click="confirmDelete">{{ t('common.delete') }}</VButton>
|
||||||
|
<VButton icon="fas fa-times" @click="router.push('/')">{{ t('common.cancel') }}</VButton>
|
||||||
|
<VButton variant="success" icon="fas fa-save" :loading="saving" @click="saveProxy">{{ t('common.save') }}</VButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="settings-form">
|
||||||
|
<!-- Статус -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ t('proxies.status') }}:</label>
|
||||||
|
<div class="status-toggle">
|
||||||
|
<button class="status-btn" :class="{ active: form.enabled }" @click="form.enabled = true">
|
||||||
|
<i class="fas fa-check-circle"></i> {{ t('common.enabled') }}
|
||||||
|
</button>
|
||||||
|
<button class="status-btn" :class="{ active: !form.enabled }" @click="form.enabled = false">
|
||||||
|
<i class="fas fa-times-circle"></i> {{ t('common.disabled') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Адрес и порт -->
|
||||||
|
<div class="form-row">
|
||||||
|
<VInput v-model="form.localAddr" :label="t('proxies.formLocalAddr')" required />
|
||||||
|
<VInput v-model="form.localPort" :label="t('proxies.formLocalPort')" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toggles -->
|
||||||
|
<div class="form-row form-row-3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ t('proxies.formServiceHttps') }}:</label>
|
||||||
|
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ t('proxies.formAutoHttps') }}:</label>
|
||||||
|
<VToggle v-model="form.autoHttps" :label="t('common.enabled')" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Auto SSL:</label>
|
||||||
|
<VToggle v-model="form.autoSSL" :label="t('common.enabled')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
156
front_vue/src/Design/views/SettingsView.vue
Normal file
156
front_vue/src/Design/views/SettingsView.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const { success } = useNotification()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
mysqlHost: '',
|
||||||
|
mysqlPort: 3306,
|
||||||
|
phpHost: '',
|
||||||
|
phpPort: 8000,
|
||||||
|
proxyEnabled: false,
|
||||||
|
acmeEnabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await configStore.load()
|
||||||
|
const s = configStore.softSettings
|
||||||
|
form.mysqlHost = s.mysql_host || '127.0.0.1'
|
||||||
|
form.mysqlPort = s.mysql_port || 3306
|
||||||
|
form.phpHost = s.php_host || 'localhost'
|
||||||
|
form.phpPort = s.php_port || 8000
|
||||||
|
form.proxyEnabled = s.proxy_enabled || false
|
||||||
|
form.acmeEnabled = s.ACME_enabled || false
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
saving.value = true
|
||||||
|
const configData = {
|
||||||
|
...configStore.data,
|
||||||
|
Soft_Settings: {
|
||||||
|
mysql_host: form.mysqlHost,
|
||||||
|
mysql_port: Number(form.mysqlPort),
|
||||||
|
php_host: form.phpHost,
|
||||||
|
php_port: Number(form.phpPort),
|
||||||
|
proxy_enabled: form.proxyEnabled,
|
||||||
|
ACME_enabled: form.acmeEnabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await configStore.save(configData)
|
||||||
|
await configStore.restartAll()
|
||||||
|
saving.value = false
|
||||||
|
success(t('notify.settingsSaved'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleProxy = async () => {
|
||||||
|
form.proxyEnabled = !form.proxyEnabled
|
||||||
|
if (form.proxyEnabled) await configStore.enableProxy()
|
||||||
|
else await configStore.disableProxy()
|
||||||
|
success(form.proxyEnabled ? t('notify.proxyEnabled') : t('notify.proxyDisabled'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAcme = async () => {
|
||||||
|
form.acmeEnabled = !form.acmeEnabled
|
||||||
|
if (form.acmeEnabled) await configStore.enableACME()
|
||||||
|
else await configStore.disableACME()
|
||||||
|
success(form.acmeEnabled ? t('notify.certManagerEnabled') : t('notify.certManagerDisabled'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="settings-view">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2 class="section-title">{{ t('settings.title') }}</h2>
|
||||||
|
<VButton variant="success" icon="fas fa-save" :loading="saving" @click="saveSettings">
|
||||||
|
{{ saving ? t('settings.saving') : t('settings.save') }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-card-title"><i class="fas fa-database"></i> {{ t('settings.mysql') }}</h3>
|
||||||
|
<div class="settings-form">
|
||||||
|
<VInput v-model="form.mysqlHost" :label="t('settings.hostAddr')" placeholder="127.0.0.1" />
|
||||||
|
<VInput v-model="form.mysqlPort" :label="t('settings.port')" type="number" placeholder="3306" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-card-title"><i class="fab fa-php"></i> {{ t('settings.php') }}</h3>
|
||||||
|
<div class="settings-form">
|
||||||
|
<VInput v-model="form.phpHost" :label="t('settings.hostAddr')" placeholder="localhost" />
|
||||||
|
<VInput v-model="form.phpPort" :label="t('settings.port')" type="number" placeholder="8000" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-card-title">
|
||||||
|
<VTooltip :text="t('settings.proxyHint')" />
|
||||||
|
<i class="fas fa-network-wired"></i> {{ t('settings.proxyManager') }}
|
||||||
|
</h3>
|
||||||
|
<div class="settings-form">
|
||||||
|
<VToggle v-model="form.proxyEnabled" :label="t('settings.proxyManager')" @update:model-value="toggleProxy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-card-title">
|
||||||
|
<VTooltip :text="t('settings.certHint')" />
|
||||||
|
<i class="fas fa-certificate"></i> {{ t('settings.certManager') }}
|
||||||
|
</h3>
|
||||||
|
<div class="settings-form">
|
||||||
|
<VToggle v-model="form.acmeEnabled" :label="t('settings.certManager')" @update:model-value="toggleAcme" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.settings-view {
|
||||||
|
animation: fadeIn var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background: var(--glass-bg-light);
|
||||||
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--divider-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-title i {
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.settings-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
89
front_vue/src/Design/views/SiteCreateView.vue
Normal file
89
front_vue/src/Design/views/SiteCreateView.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const sitesStore = useSitesStore()
|
||||||
|
const { success, error } = useNotification()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
alias: '',
|
||||||
|
rootFile: 'index.html',
|
||||||
|
status: 'active',
|
||||||
|
routing: true,
|
||||||
|
certMode: 'none',
|
||||||
|
})
|
||||||
|
|
||||||
|
const creating = ref(false)
|
||||||
|
|
||||||
|
const rootFileOptions = [
|
||||||
|
{ value: 'index.html', label: 'index.html' },
|
||||||
|
{ value: 'index.php', label: 'index.php' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusOptions = computed(() => [
|
||||||
|
{ value: 'active', label: `Active (${t('common.enabled')})` },
|
||||||
|
{ value: 'inactive', label: `Inactive (${t('common.disabled')})` },
|
||||||
|
])
|
||||||
|
|
||||||
|
const createSite = async () => {
|
||||||
|
if (!form.name || !form.host) return
|
||||||
|
creating.value = true
|
||||||
|
const siteData = {
|
||||||
|
name: form.name,
|
||||||
|
host: form.host,
|
||||||
|
alias: form.alias ? form.alias.split(',').map(a => a.trim()).filter(Boolean) : [],
|
||||||
|
root_file: form.rootFile,
|
||||||
|
status: form.status,
|
||||||
|
root_file_routing: form.routing,
|
||||||
|
AutoCreateSSL: form.certMode === 'auto',
|
||||||
|
}
|
||||||
|
const result = await sitesStore.create(siteData)
|
||||||
|
creating.value = false
|
||||||
|
if (result === 'OK') {
|
||||||
|
success(t('notify.siteCreated'))
|
||||||
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
error(String(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="vaccess-page">
|
||||||
|
<Breadcrumbs :items="[t('sites.create')]" />
|
||||||
|
|
||||||
|
<PageHeader icon="fas fa-plus-circle" :title="t('sites.create')" :subtitle="t('sites.createDesc')">
|
||||||
|
<template #actions>
|
||||||
|
<VButton variant="success" icon="fas fa-check" :loading="creating" @click="createSite">
|
||||||
|
{{ t('sites.createBtn') }}
|
||||||
|
</VButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="settings-form">
|
||||||
|
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> {{ t('common.basicInfo') }}</h3>
|
||||||
|
|
||||||
|
<VInput v-model="form.name" :label="t('sites.formName')" :placeholder="t('sites.formNamePlaceholder')" required />
|
||||||
|
<VInput v-model="form.host" :label="t('sites.formHost')" :placeholder="t('sites.formHostPlaceholder')" :hint="t('sites.formHostHint')" required />
|
||||||
|
<VInput v-model="form.alias" :label="t('sites.formAlias')" :placeholder="t('sites.formAliasPlaceholder')" :hint="t('sites.formAliasHint')" />
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<VSelect v-model="form.rootFile" :label="t('sites.formRootFile')" :options="rootFileOptions" required />
|
||||||
|
<VSelect v-model="form.status" :label="t('sites.formStatus')" :options="statusOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-label-row">
|
||||||
|
<VTooltip :text="t('sites.formRoutingHint')" />
|
||||||
|
<label class="form-label">{{ t('sites.formRouting') }}</label>
|
||||||
|
</div>
|
||||||
|
<VToggle v-model="form.routing" :label="t('common.enabled')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SslUploadSection v-model="form.certMode" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
239
front_vue/src/Design/views/SiteEditView.vue
Normal file
239
front_vue/src/Design/views/SiteEditView.vue
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<script setup>
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const sitesStore = useSitesStore()
|
||||||
|
const { success, error } = useNotification()
|
||||||
|
const modal = useModal()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
host: { type: String, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
alias: [],
|
||||||
|
rootFile: 'index.html',
|
||||||
|
status: 'active',
|
||||||
|
routing: true,
|
||||||
|
autoSSL: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const aliasInput = ref('')
|
||||||
|
|
||||||
|
const rootFileOptions = [
|
||||||
|
{ value: 'index.html', label: 'index.html' },
|
||||||
|
{ value: 'index.php', label: 'index.php' },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!sitesStore.loaded) await sitesStore.load()
|
||||||
|
const site = sitesStore.list.find(s => s.host === props.host)
|
||||||
|
if (site) {
|
||||||
|
form.name = site.name
|
||||||
|
form.host = site.host
|
||||||
|
form.alias = [...(site.alias || [])]
|
||||||
|
form.rootFile = site.root_file
|
||||||
|
form.status = site.status
|
||||||
|
form.routing = site.root_file_routing
|
||||||
|
form.autoSSL = site.AutoCreateSSL || false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const addAlias = () => {
|
||||||
|
const val = aliasInput.value.trim()
|
||||||
|
if (val && !form.alias.includes(val)) {
|
||||||
|
form.alias.push(val)
|
||||||
|
aliasInput.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAlias = (index) => {
|
||||||
|
form.alias.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSite = async () => {
|
||||||
|
saving.value = true
|
||||||
|
success(t('notify.dataSaved'))
|
||||||
|
saving.value = false
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
modal.open({
|
||||||
|
title: t('sites.deleteTitle'),
|
||||||
|
message: t('sites.deleteConfirm', { name: form.name, host: form.host }),
|
||||||
|
warning: t('sites.deleteWarning'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const result = await sitesStore.remove(form.host)
|
||||||
|
if (result === 'OK') {
|
||||||
|
success(t('notify.siteDeleted'))
|
||||||
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
error(String(result))
|
||||||
|
}
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="vaccess-page">
|
||||||
|
<Breadcrumbs :items="[host]" />
|
||||||
|
|
||||||
|
<PageHeader icon="fas fa-edit" :title="`${t('sites.edit')} — ${host}`">
|
||||||
|
<template #actions>
|
||||||
|
<VButton variant="danger" icon="fas fa-trash" @click="confirmDelete">{{ t('common.delete') }}</VButton>
|
||||||
|
<VButton icon="fas fa-times" @click="router.push('/')">{{ t('common.cancel') }}</VButton>
|
||||||
|
<VButton variant="success" icon="fas fa-save" :loading="saving" @click="saveSite">{{ t('common.save') }}</VButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="settings-form">
|
||||||
|
<!-- Статус -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ t('sites.formStatus') }}:</label>
|
||||||
|
<div class="status-toggle">
|
||||||
|
<button class="status-btn" :class="{ active: form.status === 'active' }" @click="form.status = 'active'">
|
||||||
|
<i class="fas fa-check-circle"></i> Active
|
||||||
|
</button>
|
||||||
|
<button class="status-btn" :class="{ active: form.status === 'inactive' }" @click="form.status = 'inactive'">
|
||||||
|
<i class="fas fa-times-circle"></i> Inactive
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Основная информация -->
|
||||||
|
<VInput v-model="form.name" :label="t('sites.formName')" required />
|
||||||
|
|
||||||
|
<!-- Alias с тегами -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ t('sites.formAlias') }}:</label>
|
||||||
|
<div class="tag-input-row">
|
||||||
|
<input v-model="aliasInput" class="form-input" :placeholder="t('sites.formAliasPlaceholder')" @keydown.enter.prevent="addAlias">
|
||||||
|
<button class="action-btn" @click="addAlias"><i class="fas fa-plus"></i> {{ t('common.add') }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.alias.length" class="tags-container">
|
||||||
|
<span v-for="(alias, i) in form.alias" :key="alias" class="tag">
|
||||||
|
{{ alias }}
|
||||||
|
<button class="tag-remove" @click="removeAlias(i)"><i class="fas fa-times"></i></button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Root File -->
|
||||||
|
<VSelect v-model="form.rootFile" :label="t('sites.formRootFile')" :options="rootFileOptions" />
|
||||||
|
|
||||||
|
<!-- Toggles -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ t('sites.formRouting') }}:</label>
|
||||||
|
<VToggle v-model="form.routing" :label="t('common.enabled')" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Auto SSL:</label>
|
||||||
|
<VToggle v-model="form.autoSSL" :label="t('common.enabled')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tag-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--glass-bg-dark);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
outline: none;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.5);
|
||||||
|
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.25);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tags-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--glass-bg-dark);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.2);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.4);
|
||||||
|
border-radius: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-remove {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent-red);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-remove:hover {
|
||||||
|
background: rgba(var(--danger-rgb), 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
548
front_vue/src/Design/views/VAccessView.vue
Normal file
548
front_vue/src/Design/views/VAccessView.vue
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
<script setup>
|
||||||
|
import { api } from '@core/api/index.js'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const { success } = useNotification()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
host: { type: String, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTab = ref('rules')
|
||||||
|
const rules = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const data = await api.getVAccessRules(props.host, false)
|
||||||
|
rules.value = data?.rules || []
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveRules = async () => {
|
||||||
|
await api.saveVAccessRules(props.host, false, JSON.stringify({ rules: rules.value }))
|
||||||
|
success(t('notify.changesSaved'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRule = () => {
|
||||||
|
rules.value.push({
|
||||||
|
type: 'Disable',
|
||||||
|
type_file: [],
|
||||||
|
path_access: [],
|
||||||
|
ip_list: [],
|
||||||
|
exceptions_dir: [],
|
||||||
|
url_error: '404',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRule = (index) => {
|
||||||
|
rules.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatList = (arr) => {
|
||||||
|
if (!arr || arr.length === 0) return '—'
|
||||||
|
return arr.join(', ')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="vaccess-page">
|
||||||
|
<Breadcrumbs :items="[host]">
|
||||||
|
<template #tabs>
|
||||||
|
<button class="vaccess-tab" :class="{ active: activeTab === 'rules' }" @click="activeTab = 'rules'">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
<span>{{ t('vaccess.rulesTab') }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="vaccess-tab" :class="{ active: activeTab === 'help' }" @click="activeTab = 'help'">
|
||||||
|
<i class="fas fa-question-circle"></i>
|
||||||
|
<span>{{ t('vaccess.helpTab') }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
<PageHeader icon="fas fa-shield-alt" :title="t('vaccess.title')" :subtitle="`${t('vaccess.subtitle')} — ${host}`">
|
||||||
|
<template #actions>
|
||||||
|
<VButton variant="success" icon="fas fa-save" @click="saveRules">{{ t('vaccess.save') }}</VButton>
|
||||||
|
<VButton icon="fas fa-plus" @click="addRule">{{ t('vaccess.addRule') }}</VButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Вкладка: Правила -->
|
||||||
|
<div v-if="activeTab === 'rules'" class="vaccess-tab-content">
|
||||||
|
<div v-if="rules.length === 0 && !loading" class="vaccess-empty">
|
||||||
|
<div class="empty-icon"><i class="fas fa-shield-alt"></i></div>
|
||||||
|
<h3>{{ t('vaccess.empty') }}</h3>
|
||||||
|
<p>{{ t('vaccess.emptyDesc') }}</p>
|
||||||
|
<VButton icon="fas fa-plus" @click="addRule">{{ t('vaccess.createRule') }}</VButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="vaccess-rules-container">
|
||||||
|
<table class="vaccess-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-drag"></th>
|
||||||
|
<th class="col-type">{{ t('vaccess.type') }}</th>
|
||||||
|
<th class="col-files">{{ t('vaccess.files') }}</th>
|
||||||
|
<th class="col-paths">{{ t('vaccess.paths') }}</th>
|
||||||
|
<th class="col-ips">{{ t('vaccess.ips') }}</th>
|
||||||
|
<th class="col-exceptions">{{ t('vaccess.exceptions') }}</th>
|
||||||
|
<th class="col-error">{{ t('vaccess.error') }}</th>
|
||||||
|
<th class="col-actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(rule, index) in rules" :key="index">
|
||||||
|
<td class="drag-handle"><i class="fas fa-grip-vertical"></i></td>
|
||||||
|
<td>
|
||||||
|
<VBadge :variant="rule.type === 'Allow' ? 'yes' : 'no'">{{ rule.type }}</VBadge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="rule.type_file?.length" class="mini-tags">
|
||||||
|
<code v-for="f in rule.type_file" :key="f">{{ f }}</code>
|
||||||
|
</span>
|
||||||
|
<span v-else class="empty-field">—</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="rule.path_access?.length" class="mini-tags">
|
||||||
|
<code v-for="p in rule.path_access" :key="p">{{ p }}</code>
|
||||||
|
</span>
|
||||||
|
<span v-else class="empty-field">—</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="rule.ip_list?.length" class="mini-tags">
|
||||||
|
<code v-for="ip in rule.ip_list" :key="ip">{{ ip }}</code>
|
||||||
|
</span>
|
||||||
|
<span v-else class="empty-field">—</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="rule.exceptions_dir?.length" class="mini-tags">
|
||||||
|
<code v-for="e in rule.exceptions_dir" :key="e">{{ e }}</code>
|
||||||
|
</span>
|
||||||
|
<span v-else class="empty-field">—</span>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ rule.url_error || '—' }}</code></td>
|
||||||
|
<td class="col-actions-cell">
|
||||||
|
<button class="icon-btn-small" @click="removeRule(index)"><i class="fas fa-trash"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладка: Инструкция -->
|
||||||
|
<div v-if="activeTab === 'help'" class="vaccess-tab-content">
|
||||||
|
<div class="vaccess-help">
|
||||||
|
<div class="help-card">
|
||||||
|
<h3><i class="fas fa-info-circle"></i> {{ t('vaccess.helpPrinciple') }}</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Правила проверяются <strong>сверху вниз</strong> по порядку</li>
|
||||||
|
<li>Первое подходящее правило срабатывает и завершает проверку</li>
|
||||||
|
<li>Если ни одно правило не сработало — доступ <strong>разрешён</strong></li>
|
||||||
|
<li>Перетаскивайте правила за <i class="fas fa-grip-vertical"></i> чтобы изменить порядок</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-card">
|
||||||
|
<h3><i class="fas fa-sliders-h"></i> {{ t('vaccess.helpParams') }}</h3>
|
||||||
|
<div class="help-params">
|
||||||
|
<div class="help-param">
|
||||||
|
<strong>type:</strong>
|
||||||
|
<p><VBadge variant="yes">Allow</VBadge> (разрешить) или <VBadge variant="no">Disable</VBadge> (запретить)</p>
|
||||||
|
</div>
|
||||||
|
<div class="help-param">
|
||||||
|
<strong>Расширения файлов:</strong>
|
||||||
|
<p>Список расширений через запятую (<code>*.php</code>, <code>*.exe</code>)</p>
|
||||||
|
</div>
|
||||||
|
<div class="help-param">
|
||||||
|
<strong>Пути доступа:</strong>
|
||||||
|
<p>Список путей через запятую (<code>/admin/*</code>, <code>/api/*</code>)</p>
|
||||||
|
</div>
|
||||||
|
<div class="help-param">
|
||||||
|
<strong>IP адреса:</strong>
|
||||||
|
<p>Список IP адресов через запятую (<code>192.168.1.1</code>, <code>10.0.0.5</code>)</p>
|
||||||
|
<p class="help-warning"><i class="fas fa-exclamation-triangle"></i> Используется реальный IP соединения (не заголовки прокси!)</p>
|
||||||
|
</div>
|
||||||
|
<div class="help-param">
|
||||||
|
<strong>Исключения:</strong>
|
||||||
|
<p>Пути-исключения через запятую (<code>/bot/*</code>, <code>/public/*</code>). Правило НЕ применяется к этим путям</p>
|
||||||
|
</div>
|
||||||
|
<div class="help-param">
|
||||||
|
<strong>Страница ошибки:</strong>
|
||||||
|
<p>Куда перенаправить при блокировке:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>404</code> — стандартная страница ошибки</li>
|
||||||
|
<li><code>https://site.com</code> — внешний редирект</li>
|
||||||
|
<li><code>/error.html</code> — локальная страница</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-card">
|
||||||
|
<h3><i class="fas fa-search"></i> {{ t('vaccess.helpPatterns') }}</h3>
|
||||||
|
<div class="help-patterns">
|
||||||
|
<div class="pattern-item"><code>*.ext</code><span>Любой файл с расширением .ext</span></div>
|
||||||
|
<div class="pattern-item"><code>no_extension</code><span>Файлы без расширения</span></div>
|
||||||
|
<div class="pattern-item"><code>/path/*</code><span>Все файлы в папке /path/ и подпапках</span></div>
|
||||||
|
<div class="pattern-item"><code>/file.php</code><span>Конкретный файл</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-card help-examples">
|
||||||
|
<h3><i class="fas fa-lightbulb"></i> {{ t('vaccess.helpExamples') }}</h3>
|
||||||
|
<div class="help-example">
|
||||||
|
<h4>1. Запретить выполнение PHP в uploads</h4>
|
||||||
|
<div class="example-rule">
|
||||||
|
<div><strong>Тип:</strong> <VBadge variant="no">Disable</VBadge></div>
|
||||||
|
<div><strong>Расширения:</strong> <code>*.php</code></div>
|
||||||
|
<div><strong>Пути:</strong> <code>/uploads/*</code></div>
|
||||||
|
<div><strong>Ошибка:</strong> <code>404</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-example">
|
||||||
|
<h4>2. Разрешить админку только с определённых IP</h4>
|
||||||
|
<div class="example-rule">
|
||||||
|
<div><strong>Тип:</strong> <VBadge variant="yes">Allow</VBadge></div>
|
||||||
|
<div><strong>Пути:</strong> <code>/admin/*</code></div>
|
||||||
|
<div><strong>IP:</strong> <code>192.168.1.100, 127.0.0.1</code></div>
|
||||||
|
<div><strong>Ошибка:</strong> <code>404</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vaccess-tab {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-tab:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-tab.active {
|
||||||
|
background: var(--accent-purple);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-tab-content {
|
||||||
|
animation: fadeIn var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Таблица правил */
|
||||||
|
.vaccess-rules-container {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-table thead tr {
|
||||||
|
background: rgba(var(--accent-rgb), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-table th {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-table tbody tr {
|
||||||
|
background: var(--subtle-overlay);
|
||||||
|
transition: all var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-table tbody tr:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-table td {
|
||||||
|
padding: 20px 16px;
|
||||||
|
font-size: var(--text-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-top: 1px solid rgba(var(--accent-rgb), 0.05);
|
||||||
|
border-bottom: 1px solid rgba(var(--accent-rgb), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-drag { width: 3%; min-width: 40px; text-align: center; }
|
||||||
|
.col-type { width: 8%; min-width: 80px; }
|
||||||
|
.col-files { width: 15%; min-width: 120px; }
|
||||||
|
.col-paths { width: 18%; min-width: 150px; }
|
||||||
|
.col-ips { width: 15%; min-width: 120px; }
|
||||||
|
.col-exceptions { width: 15%; min-width: 120px; }
|
||||||
|
.col-error { width: 10%; min-width: 100px; }
|
||||||
|
.col-actions { width: 5%; min-width: 60px; text-align: center; }
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: grab;
|
||||||
|
text-align: center;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-tags code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-field {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.4;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-actions-cell {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(var(--danger-rgb), 0.1);
|
||||||
|
border: 1px solid rgba(var(--danger-rgb), 0.3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--accent-red);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small:hover {
|
||||||
|
background: rgba(var(--danger-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Пустое состояние */
|
||||||
|
.vaccess-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 40px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-empty h3 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-empty p {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help секция */
|
||||||
|
.vaccess-help {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-card {
|
||||||
|
background: var(--subtle-overlay);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
transition: all var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-card:hover {
|
||||||
|
border-color: var(--glass-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-card h3 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-card ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-card li {
|
||||||
|
padding: 12px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
border-bottom: 1px solid rgba(var(--accent-rgb), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-card li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-params {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-param {
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.03);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border-left: 3px solid var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-param strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-param p {
|
||||||
|
margin: var(--space-sm) 0 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-param code {
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-param ul {
|
||||||
|
margin: 12px 0 0 20px;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-warning {
|
||||||
|
color: var(--warning-icon-color) !important;
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-patterns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: rgba(var(--accent-rgb), 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-item:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-item code {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-item span {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-examples {
|
||||||
|
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.05) 0%, rgba(var(--accent-rgb), 0.03) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-example {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-example:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-example h4 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-rule {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--subtle-overlay);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-rule div {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-rule strong {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-rule code {
|
||||||
|
color: var(--accent-purple-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
front_vue/src/main.js
Normal file
22
front_vue/src/main.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import router from '@core/router/index.js'
|
||||||
|
import i18n from '@core/i18n/index.js'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
import '@design/assets/css/variables.css'
|
||||||
|
import '@design/assets/css/themes/dark.css'
|
||||||
|
import '@design/assets/css/themes/light.css'
|
||||||
|
import '@design/assets/css/base.css'
|
||||||
|
import '@design/assets/css/forms.css'
|
||||||
|
import '@design/assets/css/tables.css'
|
||||||
|
import '@design/assets/css/transitions.css'
|
||||||
|
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(i18n)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
51
front_vue/vite.config.js
Normal file
51
front_vue/vite.config.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
|
||||||
|
AutoImport({
|
||||||
|
imports: [
|
||||||
|
'vue',
|
||||||
|
'vue-router',
|
||||||
|
'vue-i18n',
|
||||||
|
'pinia',
|
||||||
|
],
|
||||||
|
dirs: [
|
||||||
|
'src/Core/composables',
|
||||||
|
'src/Core/stores',
|
||||||
|
],
|
||||||
|
vueTemplate: true,
|
||||||
|
dts: 'src/auto-imports.d.ts',
|
||||||
|
}),
|
||||||
|
|
||||||
|
Components({
|
||||||
|
dirs: [
|
||||||
|
'src/Design/components/ui',
|
||||||
|
'src/Design/components/layout',
|
||||||
|
'src/Design/components/services',
|
||||||
|
'src/Design/components/sites',
|
||||||
|
'src/Design/components/proxies',
|
||||||
|
'src/Design/components/vaccess',
|
||||||
|
'src/Design/components/certs',
|
||||||
|
],
|
||||||
|
dts: 'src/components.d.ts',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
server: {
|
||||||
|
port: 4444,
|
||||||
|
},
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
'@core': resolve(__dirname, 'src/Core'),
|
||||||
|
'@design': resolve(__dirname, 'src/Design'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user