Создание VUE шаблона

This commit is contained in:
2026-02-08 04:13:13 +07:00
parent 4a0cfe316d
commit bdfa2404b5
80 changed files with 7688 additions and 1 deletions

13
front_vue/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

23
front_vue/package.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
<script setup>
import MainLayout from '@design/layouts/MainLayout.vue'
</script>
<template>
<MainLayout />
</template>

View 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

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

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

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

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

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

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

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

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

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

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

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

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

View 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

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

View 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

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

View 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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
<script setup>`nconst { t } = useI18n()`ndefineProps({ cert: Object })`n</script>`n<template><div class="cert-card">{{ cert?.domain }}</div></template>

View File

@@ -0,0 +1 @@
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("certs.title") }}</div></template>

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("common.edit") }}</div></template>

View File

@@ -0,0 +1 @@
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("common.edit") }}</div></template>

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

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

View File

@@ -0,0 +1 @@
test

View File

@@ -0,0 +1 @@
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("common.edit") }}</div></template>

View File

@@ -0,0 +1 @@
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("common.edit") }}</div></template>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>Field Editor</div></template>

View File

@@ -0,0 +1 @@
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("vaccess.helpTab") }}</div></template>

View File

@@ -0,0 +1 @@
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>Rule Row</div></template>

View File

@@ -0,0 +1 @@
<script setup>`nconst { t } = useI18n()`n</script>`n<template><div>{{ t("vaccess.title") }}</div></template>

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

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

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

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

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

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

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

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

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