VUE дизайн

This commit is contained in:
2026-02-08 05:37:13 +07:00
parent bdfa2404b5
commit caf52afcdf
73 changed files with 1148 additions and 7211 deletions

View File

@@ -0,0 +1 @@
ed2519d496d19e187b316251711a6b7a

View File

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

View File

@@ -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",

View File

@@ -49,6 +49,11 @@ export const mockApi = {
return 'OK'
},
async updateSiteCache() {
await delay(200)
return 'OK'
},
async openSiteFolder(host) {
await delay(100)
},

View File

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

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

View File

@@ -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!",

View File

@@ -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": "Серверы перезапущены!",

View File

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

View File

@@ -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(),

View File

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

View File

@@ -166,3 +166,10 @@
border-color: var(--btn-icon-hover-border);
transform: translateY(-1px);
}
.icon-btn:disabled {
opacity: 0.6;
cursor: wait;
transform: none;
}

View File

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

View File

@@ -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);

View File

@@ -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' }}

View File

@@ -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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

@@ -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')" />

View File

@@ -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 {

View File

@@ -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 с тегами -->

View File

@@ -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;