VUE дизайн
This commit is contained in:
@@ -1,7 +1,97 @@
|
||||
<script setup>
|
||||
import MainLayout from '@design/layouts/MainLayout.vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const servicesStore = useServicesStore()
|
||||
|
||||
const unwatch = watch(
|
||||
() => servicesStore.list,
|
||||
(list) => {
|
||||
const hasRunning = list.some(s => s.status === true)
|
||||
if (hasRunning) {
|
||||
setTimeout(() => { loading.value = false }, 300)
|
||||
unwatch()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => { loading.value = false }, 15000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="loader-fade">
|
||||
<div v-if="loading" class="app-loader">
|
||||
<div class="loader-content">
|
||||
<div class="loader-icon">🚀</div>
|
||||
<div class="loader-text">Запуск vServer...</div>
|
||||
<div class="loader-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<MainLayout />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.app-loader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary, #0f1729) 50%, var(--bg-tertiary, #1a1040) 100%);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loader-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.loader-icon {
|
||||
font-size: 64px;
|
||||
animation: rocket-bounce 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loader-text {
|
||||
font-size: var(--text-xl, 20px);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.loader-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(139, 92, 246, 0.2);
|
||||
border-top-color: var(--accent-purple, #7c3aed);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rocket-bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loader-fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.loader-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[
|
||||
{
|
||||
"Name": "Git Server",
|
||||
"Enable": true,
|
||||
"ExternalDomain": "git.example.ru",
|
||||
"LocalAddress": "127.0.0.1",
|
||||
@@ -9,6 +10,7 @@
|
||||
"AutoCreateSSL": false
|
||||
},
|
||||
{
|
||||
"Name": "API Backend",
|
||||
"Enable": true,
|
||||
"ExternalDomain": "api.example.com",
|
||||
"LocalAddress": "127.0.0.1",
|
||||
@@ -18,6 +20,7 @@
|
||||
"AutoCreateSSL": true
|
||||
},
|
||||
{
|
||||
"Name": "Test App",
|
||||
"Enable": false,
|
||||
"ExternalDomain": "test.example.net",
|
||||
"LocalAddress": "127.0.0.1",
|
||||
|
||||
@@ -49,6 +49,11 @@ export const mockApi = {
|
||||
return 'OK'
|
||||
},
|
||||
|
||||
async updateSiteCache() {
|
||||
await delay(200)
|
||||
return 'OK'
|
||||
},
|
||||
|
||||
async openSiteFolder(host) {
|
||||
await delay(100)
|
||||
},
|
||||
|
||||
@@ -37,6 +37,10 @@ export const wailsApi = {
|
||||
return await app().DeleteSite(host)
|
||||
},
|
||||
|
||||
async updateSiteCache() {
|
||||
return await app().UpdateSiteCache()
|
||||
},
|
||||
|
||||
async openSiteFolder(host) {
|
||||
return await app().OpenSiteFolder(host)
|
||||
},
|
||||
|
||||
58
front_vue/src/Core/composables/useDraggable.js
Normal file
58
front_vue/src/Core/composables/useDraggable.js
Normal file
@@ -0,0 +1,58 @@
|
||||
export function useDraggable(list) {
|
||||
const dragIndex = ref(null)
|
||||
const dragOverIndex = ref(null)
|
||||
|
||||
const onDragStart = (index, event) => {
|
||||
dragIndex.value = index
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.target.closest('tr, .draggable-item')?.classList.add('dragging')
|
||||
}
|
||||
|
||||
const onDragOver = (index, event) => {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
|
||||
const onDragEnter = (index, event) => {
|
||||
event.preventDefault()
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
|
||||
const onDragLeave = () => {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
const onDrop = (index) => {
|
||||
if (dragIndex.value === null || dragIndex.value === index) {
|
||||
dragIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const items = [...list.value]
|
||||
const [moved] = items.splice(dragIndex.value, 1)
|
||||
items.splice(index, 0, moved)
|
||||
list.value = items
|
||||
|
||||
dragIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
const onDragEnd = (event) => {
|
||||
event.target.closest('tr, .draggable-item')?.classList.remove('dragging')
|
||||
dragIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
dragIndex,
|
||||
dragOverIndex,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"app": {
|
||||
"title": "vServer Admin Panel",
|
||||
"logo": "vServer",
|
||||
"footer": "vServer Admin Panel © 2025 | Author: Sumaneev Roman",
|
||||
"footerAuthor": "Author: Sumaneev Roman",
|
||||
"loading": "Starting vServer..."
|
||||
},
|
||||
"nav": {
|
||||
@@ -18,7 +18,10 @@
|
||||
"running": "Server is running",
|
||||
"stopped": "Server is stopped",
|
||||
"start": "Start",
|
||||
"stop": "Stop"
|
||||
"stop": "Stop",
|
||||
"starting": "Starting...",
|
||||
"stopping": "Stopping...",
|
||||
"wait": "Please wait..."
|
||||
},
|
||||
"services": {
|
||||
"title": "Services Status",
|
||||
@@ -174,10 +177,11 @@
|
||||
},
|
||||
"notify": {
|
||||
"settingsSaved": "Settings saved and services restarted!",
|
||||
"settingsTestMode": "Settings saved (test mode)",
|
||||
"dataSaved": "Data saved (test mode)",
|
||||
"settingsSaved": "Settings saved",
|
||||
"dataSaved": "Data saved",
|
||||
"siteCreated": "Site created successfully!",
|
||||
"siteDeleted": "Site deleted successfully!",
|
||||
"proxyCreated": "Proxy created successfully!",
|
||||
"proxyDeleted": "Proxy deleted successfully!",
|
||||
"changesSaved": "Changes saved and applied!",
|
||||
"serversRestarted": "Servers restarted!",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"app": {
|
||||
"title": "vServer Admin Panel",
|
||||
"logo": "vServer",
|
||||
"footer": "vServer Admin Panel © 2025 | Автор: Суманеев Роман",
|
||||
"footerAuthor": "Автор: Суманеев Роман",
|
||||
"loading": "Запуск vServer..."
|
||||
},
|
||||
"nav": {
|
||||
@@ -18,7 +18,10 @@
|
||||
"running": "Сервер запущен",
|
||||
"stopped": "Сервер остановлен",
|
||||
"start": "Запустить",
|
||||
"stop": "Остановить"
|
||||
"stop": "Остановить",
|
||||
"starting": "Запускается...",
|
||||
"stopping": "Выключается...",
|
||||
"wait": "Ожидайте..."
|
||||
},
|
||||
"services": {
|
||||
"title": "Статус сервисов",
|
||||
@@ -174,10 +177,11 @@
|
||||
},
|
||||
"notify": {
|
||||
"settingsSaved": "Настройки сохранены и сервисы перезапущены!",
|
||||
"settingsTestMode": "Настройки сохранены (тестовый режим)",
|
||||
"dataSaved": "Данные сохранены (тестовый режим)",
|
||||
"settingsSaved": "Настройки сохранены",
|
||||
"dataSaved": "Данные сохранены",
|
||||
"siteCreated": "Сайт успешно создан!",
|
||||
"siteDeleted": "Сайт успешно удалён!",
|
||||
"proxyCreated": "Прокси успешно создан!",
|
||||
"proxyDeleted": "Прокси успешно удалён!",
|
||||
"changesSaved": "Изменения сохранены и применены!",
|
||||
"serversRestarted": "Серверы перезапущены!",
|
||||
|
||||
@@ -15,5 +15,38 @@ export const useProxiesStore = defineStore('proxies', {
|
||||
this.loaded = true
|
||||
}
|
||||
},
|
||||
|
||||
async create(proxyData) {
|
||||
const config = await api.getConfig()
|
||||
config.Proxy_Service.push({
|
||||
Name: proxyData.name || '',
|
||||
Enable: proxyData.enabled,
|
||||
ExternalDomain: proxyData.domain,
|
||||
LocalAddress: proxyData.localAddr,
|
||||
LocalPort: proxyData.localPort,
|
||||
ServiceHTTPSuse: proxyData.serviceHttps,
|
||||
AutoHTTPS: proxyData.autoHttps,
|
||||
AutoCreateSSL: proxyData.autoSSL,
|
||||
})
|
||||
const result = await api.saveConfig(JSON.stringify(config))
|
||||
if (result && !String(result).startsWith('Error')) {
|
||||
await api.reloadConfig()
|
||||
await this.load()
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
async remove(domain) {
|
||||
const config = await api.getConfig()
|
||||
config.Proxy_Service = config.Proxy_Service.filter(
|
||||
p => p.ExternalDomain !== domain
|
||||
)
|
||||
const result = await api.saveConfig(JSON.stringify(config))
|
||||
if (result && !String(result).startsWith('Error')) {
|
||||
await api.reloadConfig()
|
||||
await this.load()
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,17 +5,28 @@ export const useServicesStore = defineStore('services', {
|
||||
state: () => ({
|
||||
list: [],
|
||||
loaded: false,
|
||||
isOperating: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async load() {
|
||||
const data = await api.getAllServicesStatus()
|
||||
if (data) {
|
||||
this.list = Array.isArray(data) ? data : [data]
|
||||
this.list = Array.isArray(data) ? data : Object.values(data)
|
||||
this.loaded = true
|
||||
}
|
||||
},
|
||||
|
||||
setPending(text) {
|
||||
this.isOperating = true
|
||||
this.list = this.list.map(s => ({ ...s, pending: text }))
|
||||
},
|
||||
|
||||
async clearPending() {
|
||||
await this.load()
|
||||
this.isOperating = false
|
||||
},
|
||||
|
||||
async startService(name) {
|
||||
const methods = {
|
||||
HTTP: () => api.startHTTPService(),
|
||||
|
||||
@@ -77,3 +77,12 @@ input, select, textarea, button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.icon-spin {
|
||||
animation: icon-spin-anim 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-spin-anim {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -166,3 +166,10 @@
|
||||
border-color: var(--btn-icon-hover-border);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script setup>
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const openSite = () => {
|
||||
if (window.runtime?.BrowserOpenURL) {
|
||||
window.runtime.BrowserOpenURL('https://vserf.ru')
|
||||
} else {
|
||||
window.open('https://vserf.ru', '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="footer">
|
||||
<p>{{ t('app.footer') }}</p>
|
||||
<p>vServer Admin Panel © 2025-{{ currentYear }} | <a href="#" class="footer-link" @click.prevent="openSite">https://vserf.ru</a> | {{ t('app.footerAuthor') }}</p>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
@@ -13,8 +23,18 @@ const { t } = useI18n()
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-size: var(--text-sm);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
background: var(--glass-bg-dark);
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: underline;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<script setup>
|
||||
import { api } from '@core/api/index.js'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const servicesStore = useServicesStore()
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
|
||||
const isWails = typeof window !== 'undefined' && window?.runtime
|
||||
const operating = ref(false)
|
||||
const statusLabel = ref('')
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const toggleLocale = () => {
|
||||
const next = locale.value === 'ru' ? 'en' : 'ru'
|
||||
locale.value = next
|
||||
@@ -10,12 +19,42 @@ const toggleLocale = () => {
|
||||
}
|
||||
|
||||
const serverToggle = async () => {
|
||||
if (operating.value) return
|
||||
|
||||
if (appStore.serverRunning) {
|
||||
operating.value = true
|
||||
statusLabel.value = t('server.stopping')
|
||||
servicesStore.setPending(t('server.stopping'))
|
||||
await api.stopServer()
|
||||
await sleep(1500)
|
||||
servicesStore.clearPending()
|
||||
appStore.setServerRunning(false)
|
||||
statusLabel.value = ''
|
||||
operating.value = false
|
||||
} else {
|
||||
operating.value = true
|
||||
statusLabel.value = t('server.starting')
|
||||
servicesStore.setPending(t('server.starting'))
|
||||
await api.startServer()
|
||||
|
||||
let attempts = 0
|
||||
while (attempts < 20) {
|
||||
await sleep(500)
|
||||
const ready = await api.checkServicesReady()
|
||||
if (ready) break
|
||||
attempts++
|
||||
}
|
||||
|
||||
servicesStore.clearPending()
|
||||
appStore.setServerRunning(true)
|
||||
statusLabel.value = ''
|
||||
operating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const windowMinimise = () => { if (isWails) window.runtime.WindowMinimise() }
|
||||
const windowMaximise = () => { if (isWails) window.runtime.WindowToggleMaximise() }
|
||||
const windowClose = () => { if (isWails) window.runtime.Quit() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,14 +65,14 @@ const serverToggle = async () => {
|
||||
<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>
|
||||
<span class="status-indicator" :class="[operating ? 'status-pending' : (appStore.serverRunning ? 'status-online' : 'status-offline')]"></span>
|
||||
<span class="status-text">{{ statusLabel || (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">
|
||||
<button class="server-control-btn" :disabled="operating" @click="serverToggle">
|
||||
<i class="fas fa-power-off"></i>
|
||||
<span class="btn-text">{{ appStore.serverRunning ? t('server.stop') : t('server.start') }}</span>
|
||||
<span class="btn-text">{{ operating ? t('server.wait') : (appStore.serverRunning ? t('server.stop') : t('server.start')) }}</span>
|
||||
</button>
|
||||
<button class="locale-btn" @click="toggleLocale" :title="locale === 'ru' ? 'English' : 'Русский'">
|
||||
{{ locale === 'ru' ? 'EN' : 'RU' }}
|
||||
@@ -41,13 +80,13 @@ const serverToggle = async () => {
|
||||
<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')">
|
||||
<button class="window-btn minimize-btn" :title="t('window.minimize')" @click="windowMinimise">
|
||||
<i class="fas fa-window-minimize"></i>
|
||||
</button>
|
||||
<button class="window-btn maximize-btn" :title="t('window.maximize')">
|
||||
<button class="window-btn maximize-btn" :title="t('window.maximize')" @click="windowMaximise">
|
||||
<i class="far fa-window-maximize"></i>
|
||||
</button>
|
||||
<button class="window-btn close-btn" :title="t('window.close')">
|
||||
<button class="window-btn close-btn" :title="t('window.close')" @click="windowClose">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -112,6 +151,17 @@ const serverToggle = async () => {
|
||||
box-shadow: 0 0 8px var(--accent-red);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: var(--accent-yellow, #f0ad4e);
|
||||
box-shadow: 0 0 8px var(--accent-yellow, #f0ad4e);
|
||||
animation: pulse-pending 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-pending {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -4,6 +4,14 @@ const router = useRouter()
|
||||
const proxiesStore = useProxiesStore()
|
||||
const certsStore = useCertsStore()
|
||||
|
||||
const openUrl = (host) => {
|
||||
if (window.runtime?.BrowserOpenURL) {
|
||||
window.runtime.BrowserOpenURL('http://' + host)
|
||||
} else {
|
||||
window.open('http://' + host, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const findCertForDomain = (domain) => {
|
||||
const direct = certsStore.list.find(c => c.domain === domain && c.has_cert)
|
||||
if (direct) return direct
|
||||
@@ -43,10 +51,10 @@ const findCertForDomain = (domain) => {
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('sites.name') }}</th>
|
||||
<th>{{ t('proxies.externalDomain') }}</th>
|
||||
<th>{{ t('proxies.localAddress') }}</th>
|
||||
<th>{{ t('proxies.httpsCol') }}</th>
|
||||
<th>{{ t('proxies.autoHttps') }}</th>
|
||||
<th>HTTPS</th>
|
||||
<th>{{ t('proxies.status') }}</th>
|
||||
<th>{{ t('proxies.actions') }}</th>
|
||||
</tr>
|
||||
@@ -54,10 +62,13 @@ const findCertForDomain = (domain) => {
|
||||
<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} дн.)`) : 'Нет сертификата'">
|
||||
<span class="cert-icon" :class="findCertForDomain(proxy.ExternalDomain) ? (findCertForDomain(proxy.ExternalDomain).is_expired ? 'cert-expired' : 'cert-valid') : 'cert-none'">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</span>
|
||||
<code class="clickable-link">{{ proxy.ExternalDomain }} <i class="fas fa-external-link-alt"></i></code>
|
||||
{{ proxy.Name || '—' }}
|
||||
</td>
|
||||
<td>
|
||||
<code class="clickable-link" @click="openUrl(proxy.ExternalDomain)">{{ proxy.ExternalDomain }} <i class="fas fa-external-link-alt"></i></code>
|
||||
</td>
|
||||
<td><code>{{ proxy.LocalAddress }}:{{ proxy.LocalPort }}</code></td>
|
||||
<td>
|
||||
@@ -65,11 +76,6 @@ const findCertForDomain = (domain) => {
|
||||
{{ 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' }}
|
||||
|
||||
@@ -29,8 +29,8 @@ const serviceInfoLabel = {
|
||||
<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 :variant="service.pending ? 'pending' : (service.status ? 'online' : 'offline')">
|
||||
{{ service.pending || (service.status ? t('common.enabled') : t('common.disabled')) }}
|
||||
</VBadge>
|
||||
</div>
|
||||
<div class="service-info">
|
||||
|
||||
@@ -6,6 +6,22 @@ const certsStore = useCertsStore()
|
||||
const { success, error } = useNotification()
|
||||
const modal = useModal()
|
||||
|
||||
const openingFolder = ref('')
|
||||
|
||||
const openUrl = (host) => {
|
||||
if (window.runtime?.BrowserOpenURL) {
|
||||
window.runtime.BrowserOpenURL('http://' + host)
|
||||
} else {
|
||||
window.open('http://' + host, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const openFolder = async (host) => {
|
||||
openingFolder.value = host
|
||||
await sitesStore.openFolder(host)
|
||||
setTimeout(() => { openingFolder.value = '' }, 800)
|
||||
}
|
||||
|
||||
const findCertForDomain = (domain, aliases = []) => {
|
||||
const allDomains = [domain, ...aliases.filter(a => !a.includes('*'))]
|
||||
for (const d of allDomains) {
|
||||
@@ -42,7 +58,7 @@ const confirmDelete = (site) => {
|
||||
warning: t('sites.deleteWarning'),
|
||||
onConfirm: async () => {
|
||||
const result = await sitesStore.remove(site.host)
|
||||
if (result === 'OK') success(t('notify.siteDeleted'))
|
||||
if (result && !String(result).startsWith('Error')) success(t('notify.siteDeleted'))
|
||||
else error(String(result))
|
||||
modal.close()
|
||||
},
|
||||
@@ -79,7 +95,7 @@ const confirmDelete = (site) => {
|
||||
{{ site.name }}
|
||||
</td>
|
||||
<td>
|
||||
<code class="clickable-link">{{ site.host }} <i class="fas fa-external-link-alt"></i></code>
|
||||
<code class="clickable-link" @click="openUrl(site.host)">{{ site.host }} <i class="fas fa-external-link-alt"></i></code>
|
||||
</td>
|
||||
<td><code>{{ site.alias?.join(', ') || '—' }}</code></td>
|
||||
<td>
|
||||
@@ -89,8 +105,9 @@ const confirmDelete = (site) => {
|
||||
</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 class="icon-btn" :title="t('sites.openFolder')" :disabled="openingFolder === site.host" @click="openFolder(site.host)">
|
||||
<i v-if="openingFolder === site.host" class="fas fa-spinner icon-spin"></i>
|
||||
<i v-else 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>
|
||||
|
||||
@@ -16,7 +16,7 @@ defineEmits(['click'])
|
||||
:disabled="disabled || loading"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<i v-if="loading" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-if="loading" class="fas fa-spinner icon-spin"></i>
|
||||
<i v-else-if="icon" :class="icon"></i>
|
||||
<span v-if="$slots.default"><slot /></span>
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
<script setup>
|
||||
import { useWailsEvents } from '@core/composables/useWailsEvents.js'
|
||||
|
||||
const { theme, isDark, toggleTheme, initTheme } = useTheme()
|
||||
const appStore = useAppStore()
|
||||
const servicesStore = useServicesStore()
|
||||
const { on, off } = useWailsEvents()
|
||||
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
|
||||
on('service:changed', (status) => {
|
||||
if (status && !servicesStore.isOperating) {
|
||||
servicesStore.list = Object.values(status)
|
||||
servicesStore.loaded = true
|
||||
const hasRunning = status.http?.status || status.https?.status
|
||||
appStore.setServerRunning(hasRunning)
|
||||
}
|
||||
})
|
||||
|
||||
on('server:already_running', () => {
|
||||
appStore.setServerRunning(false)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
off('service:changed')
|
||||
off('server:already_running')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -53,6 +75,6 @@ onMounted(() => {
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-lg);
|
||||
padding: 40px var(--space-3xl);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,8 +9,13 @@ const props = defineProps({
|
||||
|
||||
const certs = ref([])
|
||||
const loading = ref(true)
|
||||
const issuing = ref('')
|
||||
const renewing = ref('')
|
||||
const deleting = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const refreshCerts = 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('*.', '.*\\.'))))
|
||||
@@ -19,25 +24,47 @@ onMounted(async () => {
|
||||
const info = await certsStore.getInfo(props.host)
|
||||
if (info && info.has_cert) certs.value = [info]
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCerts()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
const issueCert = async (domain) => {
|
||||
const result = await certsStore.issue(domain)
|
||||
if (result === 'OK') success(t('notify.certIssued'))
|
||||
else error(String(result))
|
||||
issuing.value = domain
|
||||
const [result] = await Promise.all([certsStore.issue(domain), sleep(1000)])
|
||||
if (result && !String(result).startsWith('Error')) {
|
||||
success(t('notify.certIssued'))
|
||||
await refreshCerts()
|
||||
} else {
|
||||
error(String(result))
|
||||
}
|
||||
issuing.value = ''
|
||||
}
|
||||
|
||||
const renewCert = async (domain) => {
|
||||
const result = await certsStore.renew(domain)
|
||||
if (result === 'OK') success(t('notify.certRenewed'))
|
||||
else error(String(result))
|
||||
renewing.value = domain
|
||||
const [result] = await Promise.all([certsStore.renew(domain), sleep(1000)])
|
||||
if (result && !String(result).startsWith('Error')) {
|
||||
success(t('notify.certRenewed'))
|
||||
await refreshCerts()
|
||||
} else {
|
||||
error(String(result))
|
||||
}
|
||||
renewing.value = ''
|
||||
}
|
||||
|
||||
const deleteCert = async (domain) => {
|
||||
const result = await certsStore.remove(domain)
|
||||
if (result === 'OK') success(t('notify.certDeleted'))
|
||||
else error(String(result))
|
||||
deleting.value = domain
|
||||
const [result] = await Promise.all([certsStore.remove(domain), sleep(1000)])
|
||||
if (result && !String(result).startsWith('Error')) {
|
||||
success(t('notify.certDeleted'))
|
||||
await refreshCerts()
|
||||
} else {
|
||||
error(String(result))
|
||||
}
|
||||
deleting.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -53,7 +80,7 @@ const deleteCert = async (domain) => {
|
||||
<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>
|
||||
<VButton icon="fas fa-plus" :loading="issuing === host" @click="issueCert(host)">{{ t('certs.issue') }}</VButton>
|
||||
</div>
|
||||
|
||||
<!-- Карточки сертификатов -->
|
||||
@@ -64,13 +91,13 @@ const deleteCert = async (domain) => {
|
||||
<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)">
|
||||
<VButton v-if="cert.has_cert && !cert.is_expired" variant="success" icon="fas fa-sync" :loading="renewing === cert.domain" @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)">
|
||||
<VButton v-if="!cert.has_cert || cert.is_expired" icon="fas fa-plus" :loading="issuing === cert.domain" @click="issueCert(cert.domain)">
|
||||
{{ t('certs.issue') }}
|
||||
</VButton>
|
||||
<VButton v-if="cert.has_cert" variant="danger" icon="fas fa-trash" @click="deleteCert(cert.domain)">
|
||||
<VButton v-if="cert.has_cert" variant="danger" icon="fas fa-trash" :loading="deleting === cert.domain" @click="deleteCert(cert.domain)">
|
||||
{{ t('certs.delete') }}
|
||||
</VButton>
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,13 @@ const certsStore = useCertsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
servicesStore.load(),
|
||||
sitesStore.load(),
|
||||
proxiesStore.load(),
|
||||
certsStore.loadAll(),
|
||||
])
|
||||
if (!servicesStore.loaded) {
|
||||
await servicesStore.load()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ const proxiesStore = useProxiesStore()
|
||||
const { success, error } = useNotification()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
domain: '',
|
||||
localAddr: '127.0.0.1',
|
||||
localPort: '',
|
||||
serviceHttps: false,
|
||||
autoHttps: true,
|
||||
certMode: 'none',
|
||||
})
|
||||
|
||||
@@ -18,10 +18,23 @@ const creating = ref(false)
|
||||
const createProxy = async () => {
|
||||
if (!form.domain || !form.localPort) return
|
||||
creating.value = true
|
||||
const result = await proxiesStore.load()
|
||||
const result = await proxiesStore.create({
|
||||
name: form.name,
|
||||
domain: form.domain,
|
||||
localAddr: form.localAddr,
|
||||
localPort: form.localPort,
|
||||
enabled: true,
|
||||
serviceHttps: form.serviceHttps,
|
||||
autoHttps: form.serviceHttps,
|
||||
autoSSL: false,
|
||||
})
|
||||
creating.value = false
|
||||
success(t('notify.dataSaved'))
|
||||
router.push('/')
|
||||
if (result && !String(result).startsWith('Error')) {
|
||||
success(t('notify.proxyCreated'))
|
||||
router.push('/')
|
||||
} else {
|
||||
error(String(result))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -41,6 +54,7 @@ const createProxy = async () => {
|
||||
<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="My Proxy" />
|
||||
<VInput v-model="form.domain" :label="t('proxies.formDomain')" :placeholder="t('proxies.formDomainPlaceholder')" :hint="t('proxies.formDomainHint')" required />
|
||||
|
||||
<div class="form-row">
|
||||
@@ -48,21 +62,12 @@ const createProxy = async () => {
|
||||
<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 class="form-group">
|
||||
<div class="form-label-row">
|
||||
<VTooltip :text="t('proxies.formServiceHttpsHint')" />
|
||||
<label class="form-label">HTTPS</label>
|
||||
</div>
|
||||
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
|
||||
</div>
|
||||
|
||||
<SslUploadSection v-model="form.certMode" />
|
||||
|
||||
@@ -10,6 +10,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
domain: '',
|
||||
localAddr: '127.0.0.1',
|
||||
localPort: '',
|
||||
@@ -19,12 +20,15 @@ const form = reactive({
|
||||
autoSSL: false,
|
||||
})
|
||||
|
||||
import { api } from '@core/api/index.js'
|
||||
|
||||
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.name = proxy.Name || ''
|
||||
form.domain = proxy.ExternalDomain
|
||||
form.localAddr = proxy.LocalAddress
|
||||
form.localPort = proxy.LocalPort
|
||||
@@ -37,9 +41,29 @@ onMounted(async () => {
|
||||
|
||||
const saveProxy = async () => {
|
||||
saving.value = true
|
||||
success(t('notify.dataSaved'))
|
||||
const config = await api.getConfig()
|
||||
const idx = config.Proxy_Service.findIndex(p => p.ExternalDomain === props.domain)
|
||||
if (idx >= 0) {
|
||||
config.Proxy_Service[idx].Name = form.name
|
||||
config.Proxy_Service[idx].ExternalDomain = form.domain
|
||||
config.Proxy_Service[idx].LocalAddress = form.localAddr
|
||||
config.Proxy_Service[idx].LocalPort = form.localPort
|
||||
config.Proxy_Service[idx].Enable = form.enabled
|
||||
config.Proxy_Service[idx].ServiceHTTPSuse = form.serviceHttps
|
||||
config.Proxy_Service[idx].AutoHTTPS = form.serviceHttps
|
||||
config.Proxy_Service[idx].AutoCreateSSL = form.autoSSL
|
||||
const result = await api.saveConfig(JSON.stringify(config))
|
||||
if (!String(result).startsWith('Error')) {
|
||||
await proxiesStore.load()
|
||||
success(t('notify.dataSaved'))
|
||||
router.push('/')
|
||||
} else {
|
||||
error(result)
|
||||
}
|
||||
} else {
|
||||
error('Proxy not found in config')
|
||||
}
|
||||
saving.value = false
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
@@ -47,9 +71,14 @@ const confirmDelete = () => {
|
||||
title: t('proxies.deleteTitle'),
|
||||
message: t('proxies.deleteConfirm', { domain: form.domain }),
|
||||
onConfirm: async () => {
|
||||
success(t('notify.proxyDeleted'))
|
||||
const result = await proxiesStore.remove(form.domain)
|
||||
if (result && !String(result).startsWith('Error')) {
|
||||
success(t('notify.proxyDeleted'))
|
||||
router.push('/')
|
||||
} else {
|
||||
error(String(result))
|
||||
}
|
||||
modal.close()
|
||||
router.push('/')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -82,6 +111,10 @@ const confirmDelete = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Имя и домен -->
|
||||
<VInput v-model="form.name" :label="t('sites.formName')" />
|
||||
<VInput v-model="form.domain" :label="t('proxies.externalDomain')" required />
|
||||
|
||||
<!-- Адрес и порт -->
|
||||
<div class="form-row">
|
||||
<VInput v-model="form.localAddr" :label="t('proxies.formLocalAddr')" required />
|
||||
@@ -89,15 +122,11 @@ const confirmDelete = () => {
|
||||
</div>
|
||||
|
||||
<!-- Toggles -->
|
||||
<div class="form-row form-row-3">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('proxies.formServiceHttps') }}:</label>
|
||||
<label class="form-label">HTTPS:</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')" />
|
||||
|
||||
@@ -40,7 +40,7 @@ const createSite = async () => {
|
||||
}
|
||||
const result = await sitesStore.create(siteData)
|
||||
creating.value = false
|
||||
if (result === 'OK') {
|
||||
if (result && !String(result).startsWith('Error')) {
|
||||
success(t('notify.siteCreated'))
|
||||
router.push('/')
|
||||
} else {
|
||||
|
||||
@@ -19,6 +19,8 @@ const form = reactive({
|
||||
autoSSL: false,
|
||||
})
|
||||
|
||||
import { api } from '@core/api/index.js'
|
||||
|
||||
const saving = ref(false)
|
||||
const aliasInput = ref('')
|
||||
|
||||
@@ -55,9 +57,28 @@ const removeAlias = (index) => {
|
||||
|
||||
const saveSite = async () => {
|
||||
saving.value = true
|
||||
success(t('notify.dataSaved'))
|
||||
const config = await api.getConfig()
|
||||
const idx = config.Site_www.findIndex(s => s.host === props.host)
|
||||
if (idx >= 0) {
|
||||
config.Site_www[idx].host = form.host
|
||||
config.Site_www[idx].name = form.name
|
||||
config.Site_www[idx].alias = form.alias
|
||||
config.Site_www[idx].root_file = form.rootFile
|
||||
config.Site_www[idx].status = form.status
|
||||
config.Site_www[idx].root_file_routing = form.routing
|
||||
config.Site_www[idx].AutoCreateSSL = form.autoSSL
|
||||
const result = await api.saveConfig(JSON.stringify(config))
|
||||
if (!String(result).startsWith('Error')) {
|
||||
await sitesStore.load()
|
||||
success(t('notify.dataSaved'))
|
||||
router.push('/')
|
||||
} else {
|
||||
error(result)
|
||||
}
|
||||
} else {
|
||||
error('Site not found in config')
|
||||
}
|
||||
saving.value = false
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
@@ -67,7 +88,7 @@ const confirmDelete = () => {
|
||||
warning: t('sites.deleteWarning'),
|
||||
onConfirm: async () => {
|
||||
const result = await sitesStore.remove(form.host)
|
||||
if (result === 'OK') {
|
||||
if (result && !String(result).startsWith('Error')) {
|
||||
success(t('notify.siteDeleted'))
|
||||
router.push('/')
|
||||
} else {
|
||||
@@ -107,6 +128,7 @@ const confirmDelete = () => {
|
||||
</div>
|
||||
|
||||
<!-- Основная информация -->
|
||||
<VInput v-model="form.host" :label="t('sites.host')" required />
|
||||
<VInput v-model="form.name" :label="t('sites.formName')" required />
|
||||
|
||||
<!-- Alias с тегами -->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { api } from '@core/api/index.js'
|
||||
import { useDraggable } from '@core/composables/useDraggable.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -13,6 +14,8 @@ const activeTab = ref('rules')
|
||||
const rules = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const { dragIndex, dragOverIndex, onDragStart, onDragOver, onDragEnter, onDragLeave, onDrop, onDragEnd } = useDraggable(rules)
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await api.getVAccessRules(props.host, false)
|
||||
rules.value = data?.rules || []
|
||||
@@ -91,7 +94,18 @@ const formatList = (arr) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(rule, index) in rules" :key="index">
|
||||
<tr
|
||||
v-for="(rule, index) in rules"
|
||||
:key="index"
|
||||
draggable="true"
|
||||
:class="{ 'drag-over': dragOverIndex === index, 'dragging': dragIndex === index }"
|
||||
@dragstart="onDragStart(index, $event)"
|
||||
@dragover="onDragOver(index, $event)"
|
||||
@dragenter="onDragEnter(index, $event)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop(index)"
|
||||
@dragend="onDragEnd($event)"
|
||||
>
|
||||
<td class="drag-handle"><i class="fas fa-grip-vertical"></i></td>
|
||||
<td>
|
||||
<VBadge :variant="rule.type === 'Allow' ? 'yes' : 'no'">{{ rule.type }}</VBadge>
|
||||
@@ -310,6 +324,18 @@ const formatList = (arr) => {
|
||||
color: var(--accent-purple-light);
|
||||
}
|
||||
|
||||
.vaccess-table tbody tr.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.vaccess-table tbody tr.drag-over {
|
||||
border-top: 2px solid var(--accent-purple);
|
||||
}
|
||||
|
||||
.vaccess-table tbody tr.drag-over td {
|
||||
border-top: 2px solid var(--accent-purple);
|
||||
}
|
||||
|
||||
.mini-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user