Создание VUE шаблона
This commit is contained in:
234
front_vue/src/Design/views/CertManagerView.vue
Normal file
234
front_vue/src/Design/views/CertManagerView.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<script setup>
|
||||
const { t } = useI18n()
|
||||
const certsStore = useCertsStore()
|
||||
const { success, error } = useNotification()
|
||||
|
||||
const props = defineProps({
|
||||
host: { type: String, required: true },
|
||||
})
|
||||
|
||||
const certs = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
await certsStore.loadAll()
|
||||
certs.value = certsStore.list.filter(c =>
|
||||
c.dns_names?.some(d => d === props.host || d === `*.${props.host}` || props.host.match(new RegExp(d.replace('*.', '.*\\.'))))
|
||||
)
|
||||
if (certs.value.length === 0) {
|
||||
const info = await certsStore.getInfo(props.host)
|
||||
if (info && info.has_cert) certs.value = [info]
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
const issueCert = async (domain) => {
|
||||
const result = await certsStore.issue(domain)
|
||||
if (result === 'OK') success(t('notify.certIssued'))
|
||||
else error(String(result))
|
||||
}
|
||||
|
||||
const renewCert = async (domain) => {
|
||||
const result = await certsStore.renew(domain)
|
||||
if (result === 'OK') success(t('notify.certRenewed'))
|
||||
else error(String(result))
|
||||
}
|
||||
|
||||
const deleteCert = async (domain) => {
|
||||
const result = await certsStore.remove(domain)
|
||||
if (result === 'OK') success(t('notify.certDeleted'))
|
||||
else error(String(result))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vaccess-page">
|
||||
<Breadcrumbs :items="[host, t('certs.title')]" />
|
||||
|
||||
<PageHeader icon="fas fa-shield-alt" :title="t('certs.title')" :subtitle="`${t('certs.subtitle')} — ${host}`" />
|
||||
|
||||
<div class="cert-manager-content">
|
||||
<!-- Нет сертификатов -->
|
||||
<div v-if="!loading && certs.length === 0" class="cert-empty">
|
||||
<i class="fas fa-certificate"></i>
|
||||
<h3>{{ t('certs.noCert') }}</h3>
|
||||
<p>{{ t('certs.subtitle') }}</p>
|
||||
<VButton icon="fas fa-plus" @click="issueCert(host)">{{ t('certs.issue') }}</VButton>
|
||||
</div>
|
||||
|
||||
<!-- Карточки сертификатов -->
|
||||
<div v-for="cert in certs" :key="cert.domain" class="cert-card" :class="{ 'cert-card-empty': !cert.has_cert }">
|
||||
<div class="cert-card-header">
|
||||
<div class="cert-card-title" :class="{ expired: cert.is_expired }">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<h3>{{ cert.domain }}</h3>
|
||||
</div>
|
||||
<div class="cert-card-actions">
|
||||
<VButton v-if="cert.has_cert && !cert.is_expired" variant="success" icon="fas fa-sync" @click="renewCert(cert.domain)">
|
||||
{{ t('certs.renew') }}
|
||||
</VButton>
|
||||
<VButton v-if="!cert.has_cert || cert.is_expired" icon="fas fa-plus" @click="issueCert(cert.domain)">
|
||||
{{ t('certs.issue') }}
|
||||
</VButton>
|
||||
<VButton v-if="cert.has_cert" variant="danger" icon="fas fa-trash" @click="deleteCert(cert.domain)">
|
||||
{{ t('certs.delete') }}
|
||||
</VButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cert.has_cert" class="cert-info-grid">
|
||||
<div class="cert-info-item">
|
||||
<div class="cert-info-label">{{ t('certs.status') }}</div>
|
||||
<div class="cert-info-value" :class="cert.is_expired ? 'expired' : 'valid'">
|
||||
{{ cert.is_expired ? t('certs.expired') : `${t('certs.active')} (${t('certs.daysLeft', { n: cert.days_left })})` }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-info-item">
|
||||
<div class="cert-info-label">{{ t('certs.issuer') }}</div>
|
||||
<div class="cert-info-value">{{ cert.issuer }}</div>
|
||||
</div>
|
||||
<div class="cert-info-item">
|
||||
<div class="cert-info-label">{{ t('certs.issued') }}</div>
|
||||
<div class="cert-info-value">{{ cert.not_before }}</div>
|
||||
</div>
|
||||
<div class="cert-info-item">
|
||||
<div class="cert-info-label">{{ t('certs.expires') }}</div>
|
||||
<div class="cert-info-value" :class="cert.is_expired ? 'expired' : ''">{{ cert.not_after }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cert.dns_names?.length" class="cert-domains-list">
|
||||
<span v-for="dns in cert.dns_names" :key="dns" class="cert-domain-tag">{{ dns }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cert-manager-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.cert-card {
|
||||
background: rgba(var(--accent-rgb), 0.03);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-lg);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.cert-card-empty {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.cert-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-md);
|
||||
padding-bottom: var(--space-md);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.cert-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.cert-card-title i {
|
||||
font-size: 24px;
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.cert-card-title.expired i {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.cert-card-title h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cert-card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.cert-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.cert-info-item {
|
||||
padding: var(--space-md);
|
||||
background: var(--subtle-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.cert-info-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.cert-info-value {
|
||||
font-size: var(--text-md);
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.cert-info-value.valid {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.cert-info-value.expired {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.cert-domains-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.cert-domain-tag {
|
||||
padding: 4px 12px;
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--accent-purple-light);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.cert-empty {
|
||||
text-align: center;
|
||||
padding: 60px 40px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cert-empty i {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-lg);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.cert-empty h3 {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.cert-empty p {
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
</style>
|
||||
31
front_vue/src/Design/views/DashboardView.vue
Normal file
31
front_vue/src/Design/views/DashboardView.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
const servicesStore = useServicesStore()
|
||||
const sitesStore = useSitesStore()
|
||||
const proxiesStore = useProxiesStore()
|
||||
const certsStore = useCertsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
servicesStore.load(),
|
||||
sitesStore.load(),
|
||||
proxiesStore.load(),
|
||||
certsStore.loadAll(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-view">
|
||||
<ServicesGrid />
|
||||
<SitesTable />
|
||||
<ProxiesTable />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
</style>
|
||||
72
front_vue/src/Design/views/ProxyCreateView.vue
Normal file
72
front_vue/src/Design/views/ProxyCreateView.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const proxiesStore = useProxiesStore()
|
||||
const { success, error } = useNotification()
|
||||
|
||||
const form = reactive({
|
||||
domain: '',
|
||||
localAddr: '127.0.0.1',
|
||||
localPort: '',
|
||||
serviceHttps: false,
|
||||
autoHttps: true,
|
||||
certMode: 'none',
|
||||
})
|
||||
|
||||
const creating = ref(false)
|
||||
|
||||
const createProxy = async () => {
|
||||
if (!form.domain || !form.localPort) return
|
||||
creating.value = true
|
||||
const result = await proxiesStore.load()
|
||||
creating.value = false
|
||||
success(t('notify.dataSaved'))
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vaccess-page">
|
||||
<Breadcrumbs :items="[t('proxies.create')]" />
|
||||
|
||||
<PageHeader icon="fas fa-plus-circle" :title="t('proxies.create')" :subtitle="t('proxies.createDesc')">
|
||||
<template #actions>
|
||||
<VButton variant="success" icon="fas fa-check" :loading="creating" @click="createProxy">
|
||||
{{ t('proxies.createBtn') }}
|
||||
</VButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="settings-form">
|
||||
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> {{ t('common.basicInfo') }}</h3>
|
||||
|
||||
<VInput v-model="form.domain" :label="t('proxies.formDomain')" :placeholder="t('proxies.formDomainPlaceholder')" :hint="t('proxies.formDomainHint')" required />
|
||||
|
||||
<div class="form-row">
|
||||
<VInput v-model="form.localAddr" :label="t('proxies.formLocalAddr')" placeholder="127.0.0.1" required />
|
||||
<VInput v-model="form.localPort" :label="t('proxies.formLocalPort')" placeholder="3000" required />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<div class="form-label-row">
|
||||
<VTooltip :text="t('proxies.formServiceHttpsHint')" />
|
||||
<label class="form-label">{{ t('proxies.formServiceHttps') }}</label>
|
||||
</div>
|
||||
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-label-row">
|
||||
<VTooltip :text="t('proxies.formAutoHttpsHint')" />
|
||||
<label class="form-label">{{ t('proxies.formAutoHttps') }}</label>
|
||||
</div>
|
||||
<VToggle v-model="form.autoHttps" :label="t('common.enabled')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SslUploadSection v-model="form.certMode" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
109
front_vue/src/Design/views/ProxyEditView.vue
Normal file
109
front_vue/src/Design/views/ProxyEditView.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const proxiesStore = useProxiesStore()
|
||||
const { success, error } = useNotification()
|
||||
const modal = useModal()
|
||||
|
||||
const props = defineProps({
|
||||
domain: { type: String, required: true },
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
domain: '',
|
||||
localAddr: '127.0.0.1',
|
||||
localPort: '',
|
||||
enabled: true,
|
||||
serviceHttps: false,
|
||||
autoHttps: true,
|
||||
autoSSL: false,
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!proxiesStore.loaded) await proxiesStore.load()
|
||||
const proxy = proxiesStore.list.find(p => p.ExternalDomain === props.domain)
|
||||
if (proxy) {
|
||||
form.domain = proxy.ExternalDomain
|
||||
form.localAddr = proxy.LocalAddress
|
||||
form.localPort = proxy.LocalPort
|
||||
form.enabled = proxy.Enable
|
||||
form.serviceHttps = proxy.ServiceHTTPSuse
|
||||
form.autoHttps = proxy.AutoHTTPS
|
||||
form.autoSSL = proxy.AutoCreateSSL || false
|
||||
}
|
||||
})
|
||||
|
||||
const saveProxy = async () => {
|
||||
saving.value = true
|
||||
success(t('notify.dataSaved'))
|
||||
saving.value = false
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
modal.open({
|
||||
title: t('proxies.deleteTitle'),
|
||||
message: t('proxies.deleteConfirm', { domain: form.domain }),
|
||||
onConfirm: async () => {
|
||||
success(t('notify.proxyDeleted'))
|
||||
modal.close()
|
||||
router.push('/')
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vaccess-page">
|
||||
<Breadcrumbs :items="[domain]" />
|
||||
|
||||
<PageHeader icon="fas fa-edit" :title="`${t('sites.edit')} — ${domain}`">
|
||||
<template #actions>
|
||||
<VButton variant="danger" icon="fas fa-trash" @click="confirmDelete">{{ t('common.delete') }}</VButton>
|
||||
<VButton icon="fas fa-times" @click="router.push('/')">{{ t('common.cancel') }}</VButton>
|
||||
<VButton variant="success" icon="fas fa-save" :loading="saving" @click="saveProxy">{{ t('common.save') }}</VButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="settings-form">
|
||||
<!-- Статус -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('proxies.status') }}:</label>
|
||||
<div class="status-toggle">
|
||||
<button class="status-btn" :class="{ active: form.enabled }" @click="form.enabled = true">
|
||||
<i class="fas fa-check-circle"></i> {{ t('common.enabled') }}
|
||||
</button>
|
||||
<button class="status-btn" :class="{ active: !form.enabled }" @click="form.enabled = false">
|
||||
<i class="fas fa-times-circle"></i> {{ t('common.disabled') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Адрес и порт -->
|
||||
<div class="form-row">
|
||||
<VInput v-model="form.localAddr" :label="t('proxies.formLocalAddr')" required />
|
||||
<VInput v-model="form.localPort" :label="t('proxies.formLocalPort')" required />
|
||||
</div>
|
||||
|
||||
<!-- Toggles -->
|
||||
<div class="form-row form-row-3">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('proxies.formServiceHttps') }}:</label>
|
||||
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('proxies.formAutoHttps') }}:</label>
|
||||
<VToggle v-model="form.autoHttps" :label="t('common.enabled')" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Auto SSL:</label>
|
||||
<VToggle v-model="form.autoSSL" :label="t('common.enabled')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
156
front_vue/src/Design/views/SettingsView.vue
Normal file
156
front_vue/src/Design/views/SettingsView.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
const { t } = useI18n()
|
||||
const configStore = useConfigStore()
|
||||
const { success } = useNotification()
|
||||
|
||||
const form = reactive({
|
||||
mysqlHost: '',
|
||||
mysqlPort: 3306,
|
||||
phpHost: '',
|
||||
phpPort: 8000,
|
||||
proxyEnabled: false,
|
||||
acmeEnabled: false,
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await configStore.load()
|
||||
const s = configStore.softSettings
|
||||
form.mysqlHost = s.mysql_host || '127.0.0.1'
|
||||
form.mysqlPort = s.mysql_port || 3306
|
||||
form.phpHost = s.php_host || 'localhost'
|
||||
form.phpPort = s.php_port || 8000
|
||||
form.proxyEnabled = s.proxy_enabled || false
|
||||
form.acmeEnabled = s.ACME_enabled || false
|
||||
})
|
||||
|
||||
const saveSettings = async () => {
|
||||
saving.value = true
|
||||
const configData = {
|
||||
...configStore.data,
|
||||
Soft_Settings: {
|
||||
mysql_host: form.mysqlHost,
|
||||
mysql_port: Number(form.mysqlPort),
|
||||
php_host: form.phpHost,
|
||||
php_port: Number(form.phpPort),
|
||||
proxy_enabled: form.proxyEnabled,
|
||||
ACME_enabled: form.acmeEnabled,
|
||||
},
|
||||
}
|
||||
await configStore.save(configData)
|
||||
await configStore.restartAll()
|
||||
saving.value = false
|
||||
success(t('notify.settingsSaved'))
|
||||
}
|
||||
|
||||
const toggleProxy = async () => {
|
||||
form.proxyEnabled = !form.proxyEnabled
|
||||
if (form.proxyEnabled) await configStore.enableProxy()
|
||||
else await configStore.disableProxy()
|
||||
success(form.proxyEnabled ? t('notify.proxyEnabled') : t('notify.proxyDisabled'))
|
||||
}
|
||||
|
||||
const toggleAcme = async () => {
|
||||
form.acmeEnabled = !form.acmeEnabled
|
||||
if (form.acmeEnabled) await configStore.enableACME()
|
||||
else await configStore.disableACME()
|
||||
success(form.acmeEnabled ? t('notify.certManagerEnabled') : t('notify.certManagerDisabled'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-view">
|
||||
<div class="settings-header">
|
||||
<h2 class="section-title">{{ t('settings.title') }}</h2>
|
||||
<VButton variant="success" icon="fas fa-save" :loading="saving" @click="saveSettings">
|
||||
{{ saving ? t('settings.saving') : t('settings.save') }}
|
||||
</VButton>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="settings-card">
|
||||
<h3 class="settings-card-title"><i class="fas fa-database"></i> {{ t('settings.mysql') }}</h3>
|
||||
<div class="settings-form">
|
||||
<VInput v-model="form.mysqlHost" :label="t('settings.hostAddr')" placeholder="127.0.0.1" />
|
||||
<VInput v-model="form.mysqlPort" :label="t('settings.port')" type="number" placeholder="3306" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 class="settings-card-title"><i class="fab fa-php"></i> {{ t('settings.php') }}</h3>
|
||||
<div class="settings-form">
|
||||
<VInput v-model="form.phpHost" :label="t('settings.hostAddr')" placeholder="localhost" />
|
||||
<VInput v-model="form.phpPort" :label="t('settings.port')" type="number" placeholder="8000" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 class="settings-card-title">
|
||||
<VTooltip :text="t('settings.proxyHint')" />
|
||||
<i class="fas fa-network-wired"></i> {{ t('settings.proxyManager') }}
|
||||
</h3>
|
||||
<div class="settings-form">
|
||||
<VToggle v-model="form.proxyEnabled" :label="t('settings.proxyManager')" @update:model-value="toggleProxy" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 class="settings-card-title">
|
||||
<VTooltip :text="t('settings.certHint')" />
|
||||
<i class="fas fa-certificate"></i> {{ t('settings.certManager') }}
|
||||
</h3>
|
||||
<div class="settings-form">
|
||||
<VToggle v-model="form.acmeEnabled" :label="t('settings.certManager')" @update:model-value="toggleAcme" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.settings-view {
|
||||
animation: fadeIn var(--transition-slow);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: var(--glass-bg-light);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.settings-card-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 20px 0;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--divider-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.settings-card-title i {
|
||||
color: var(--accent-purple-light);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
89
front_vue/src/Design/views/SiteCreateView.vue
Normal file
89
front_vue/src/Design/views/SiteCreateView.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const sitesStore = useSitesStore()
|
||||
const { success, error } = useNotification()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
host: '',
|
||||
alias: '',
|
||||
rootFile: 'index.html',
|
||||
status: 'active',
|
||||
routing: true,
|
||||
certMode: 'none',
|
||||
})
|
||||
|
||||
const creating = ref(false)
|
||||
|
||||
const rootFileOptions = [
|
||||
{ value: 'index.html', label: 'index.html' },
|
||||
{ value: 'index.php', label: 'index.php' },
|
||||
]
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: 'active', label: `Active (${t('common.enabled')})` },
|
||||
{ value: 'inactive', label: `Inactive (${t('common.disabled')})` },
|
||||
])
|
||||
|
||||
const createSite = async () => {
|
||||
if (!form.name || !form.host) return
|
||||
creating.value = true
|
||||
const siteData = {
|
||||
name: form.name,
|
||||
host: form.host,
|
||||
alias: form.alias ? form.alias.split(',').map(a => a.trim()).filter(Boolean) : [],
|
||||
root_file: form.rootFile,
|
||||
status: form.status,
|
||||
root_file_routing: form.routing,
|
||||
AutoCreateSSL: form.certMode === 'auto',
|
||||
}
|
||||
const result = await sitesStore.create(siteData)
|
||||
creating.value = false
|
||||
if (result === 'OK') {
|
||||
success(t('notify.siteCreated'))
|
||||
router.push('/')
|
||||
} else {
|
||||
error(String(result))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vaccess-page">
|
||||
<Breadcrumbs :items="[t('sites.create')]" />
|
||||
|
||||
<PageHeader icon="fas fa-plus-circle" :title="t('sites.create')" :subtitle="t('sites.createDesc')">
|
||||
<template #actions>
|
||||
<VButton variant="success" icon="fas fa-check" :loading="creating" @click="createSite">
|
||||
{{ t('sites.createBtn') }}
|
||||
</VButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="settings-form">
|
||||
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> {{ t('common.basicInfo') }}</h3>
|
||||
|
||||
<VInput v-model="form.name" :label="t('sites.formName')" :placeholder="t('sites.formNamePlaceholder')" required />
|
||||
<VInput v-model="form.host" :label="t('sites.formHost')" :placeholder="t('sites.formHostPlaceholder')" :hint="t('sites.formHostHint')" required />
|
||||
<VInput v-model="form.alias" :label="t('sites.formAlias')" :placeholder="t('sites.formAliasPlaceholder')" :hint="t('sites.formAliasHint')" />
|
||||
|
||||
<div class="form-row">
|
||||
<VSelect v-model="form.rootFile" :label="t('sites.formRootFile')" :options="rootFileOptions" required />
|
||||
<VSelect v-model="form.status" :label="t('sites.formStatus')" :options="statusOptions" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-label-row">
|
||||
<VTooltip :text="t('sites.formRoutingHint')" />
|
||||
<label class="form-label">{{ t('sites.formRouting') }}</label>
|
||||
</div>
|
||||
<VToggle v-model="form.routing" :label="t('common.enabled')" />
|
||||
</div>
|
||||
|
||||
<SslUploadSection v-model="form.certMode" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
239
front_vue/src/Design/views/SiteEditView.vue
Normal file
239
front_vue/src/Design/views/SiteEditView.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup>
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const sitesStore = useSitesStore()
|
||||
const { success, error } = useNotification()
|
||||
const modal = useModal()
|
||||
|
||||
const props = defineProps({
|
||||
host: { type: String, required: true },
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
host: '',
|
||||
alias: [],
|
||||
rootFile: 'index.html',
|
||||
status: 'active',
|
||||
routing: true,
|
||||
autoSSL: false,
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const aliasInput = ref('')
|
||||
|
||||
const rootFileOptions = [
|
||||
{ value: 'index.html', label: 'index.html' },
|
||||
{ value: 'index.php', label: 'index.php' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
if (!sitesStore.loaded) await sitesStore.load()
|
||||
const site = sitesStore.list.find(s => s.host === props.host)
|
||||
if (site) {
|
||||
form.name = site.name
|
||||
form.host = site.host
|
||||
form.alias = [...(site.alias || [])]
|
||||
form.rootFile = site.root_file
|
||||
form.status = site.status
|
||||
form.routing = site.root_file_routing
|
||||
form.autoSSL = site.AutoCreateSSL || false
|
||||
}
|
||||
})
|
||||
|
||||
const addAlias = () => {
|
||||
const val = aliasInput.value.trim()
|
||||
if (val && !form.alias.includes(val)) {
|
||||
form.alias.push(val)
|
||||
aliasInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeAlias = (index) => {
|
||||
form.alias.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveSite = async () => {
|
||||
saving.value = true
|
||||
success(t('notify.dataSaved'))
|
||||
saving.value = false
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
modal.open({
|
||||
title: t('sites.deleteTitle'),
|
||||
message: t('sites.deleteConfirm', { name: form.name, host: form.host }),
|
||||
warning: t('sites.deleteWarning'),
|
||||
onConfirm: async () => {
|
||||
const result = await sitesStore.remove(form.host)
|
||||
if (result === 'OK') {
|
||||
success(t('notify.siteDeleted'))
|
||||
router.push('/')
|
||||
} else {
|
||||
error(String(result))
|
||||
}
|
||||
modal.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vaccess-page">
|
||||
<Breadcrumbs :items="[host]" />
|
||||
|
||||
<PageHeader icon="fas fa-edit" :title="`${t('sites.edit')} — ${host}`">
|
||||
<template #actions>
|
||||
<VButton variant="danger" icon="fas fa-trash" @click="confirmDelete">{{ t('common.delete') }}</VButton>
|
||||
<VButton icon="fas fa-times" @click="router.push('/')">{{ t('common.cancel') }}</VButton>
|
||||
<VButton variant="success" icon="fas fa-save" :loading="saving" @click="saveSite">{{ t('common.save') }}</VButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="settings-form">
|
||||
<!-- Статус -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('sites.formStatus') }}:</label>
|
||||
<div class="status-toggle">
|
||||
<button class="status-btn" :class="{ active: form.status === 'active' }" @click="form.status = 'active'">
|
||||
<i class="fas fa-check-circle"></i> Active
|
||||
</button>
|
||||
<button class="status-btn" :class="{ active: form.status === 'inactive' }" @click="form.status = 'inactive'">
|
||||
<i class="fas fa-times-circle"></i> Inactive
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Основная информация -->
|
||||
<VInput v-model="form.name" :label="t('sites.formName')" required />
|
||||
|
||||
<!-- Alias с тегами -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('sites.formAlias') }}:</label>
|
||||
<div class="tag-input-row">
|
||||
<input v-model="aliasInput" class="form-input" :placeholder="t('sites.formAliasPlaceholder')" @keydown.enter.prevent="addAlias">
|
||||
<button class="action-btn" @click="addAlias"><i class="fas fa-plus"></i> {{ t('common.add') }}</button>
|
||||
</div>
|
||||
<div v-if="form.alias.length" class="tags-container">
|
||||
<span v-for="(alias, i) in form.alias" :key="alias" class="tag">
|
||||
{{ alias }}
|
||||
<button class="tag-remove" @click="removeAlias(i)"><i class="fas fa-times"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Root File -->
|
||||
<VSelect v-model="form.rootFile" :label="t('sites.formRootFile')" :options="rootFileOptions" />
|
||||
|
||||
<!-- Toggles -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('sites.formRouting') }}:</label>
|
||||
<VToggle v-model="form.routing" :label="t('common.enabled')" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Auto SSL:</label>
|
||||
<VToggle v-model="form.autoSSL" :label="t('common.enabled')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.tag-input-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: var(--glass-bg-dark);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-base);
|
||||
outline: none;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: rgba(var(--accent-rgb), 0.5);
|
||||
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.2);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-purple-light);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.25);
|
||||
border-color: rgba(var(--accent-rgb), 0.5);
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
padding: 12px;
|
||||
background: var(--glass-bg-dark);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 4px 10px;
|
||||
background: rgba(var(--accent-rgb), 0.2);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.4);
|
||||
border-radius: 16px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent-red);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-full);
|
||||
transition: all var(--transition-base);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background: rgba(var(--danger-rgb), 0.2);
|
||||
}
|
||||
</style>
|
||||
548
front_vue/src/Design/views/VAccessView.vue
Normal file
548
front_vue/src/Design/views/VAccessView.vue
Normal file
@@ -0,0 +1,548 @@
|
||||
<script setup>
|
||||
import { api } from '@core/api/index.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const { success } = useNotification()
|
||||
|
||||
const props = defineProps({
|
||||
host: { type: String, required: true },
|
||||
})
|
||||
|
||||
const activeTab = ref('rules')
|
||||
const rules = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await api.getVAccessRules(props.host, false)
|
||||
rules.value = data?.rules || []
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
const saveRules = async () => {
|
||||
await api.saveVAccessRules(props.host, false, JSON.stringify({ rules: rules.value }))
|
||||
success(t('notify.changesSaved'))
|
||||
}
|
||||
|
||||
const addRule = () => {
|
||||
rules.value.push({
|
||||
type: 'Disable',
|
||||
type_file: [],
|
||||
path_access: [],
|
||||
ip_list: [],
|
||||
exceptions_dir: [],
|
||||
url_error: '404',
|
||||
})
|
||||
}
|
||||
|
||||
const removeRule = (index) => {
|
||||
rules.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const formatList = (arr) => {
|
||||
if (!arr || arr.length === 0) return '—'
|
||||
return arr.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vaccess-page">
|
||||
<Breadcrumbs :items="[host]">
|
||||
<template #tabs>
|
||||
<button class="vaccess-tab" :class="{ active: activeTab === 'rules' }" @click="activeTab = 'rules'">
|
||||
<i class="fas fa-list"></i>
|
||||
<span>{{ t('vaccess.rulesTab') }}</span>
|
||||
</button>
|
||||
<button class="vaccess-tab" :class="{ active: activeTab === 'help' }" @click="activeTab = 'help'">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
<span>{{ t('vaccess.helpTab') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</Breadcrumbs>
|
||||
|
||||
<PageHeader icon="fas fa-shield-alt" :title="t('vaccess.title')" :subtitle="`${t('vaccess.subtitle')} — ${host}`">
|
||||
<template #actions>
|
||||
<VButton variant="success" icon="fas fa-save" @click="saveRules">{{ t('vaccess.save') }}</VButton>
|
||||
<VButton icon="fas fa-plus" @click="addRule">{{ t('vaccess.addRule') }}</VButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Вкладка: Правила -->
|
||||
<div v-if="activeTab === 'rules'" class="vaccess-tab-content">
|
||||
<div v-if="rules.length === 0 && !loading" class="vaccess-empty">
|
||||
<div class="empty-icon"><i class="fas fa-shield-alt"></i></div>
|
||||
<h3>{{ t('vaccess.empty') }}</h3>
|
||||
<p>{{ t('vaccess.emptyDesc') }}</p>
|
||||
<VButton icon="fas fa-plus" @click="addRule">{{ t('vaccess.createRule') }}</VButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="vaccess-rules-container">
|
||||
<table class="vaccess-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-drag"></th>
|
||||
<th class="col-type">{{ t('vaccess.type') }}</th>
|
||||
<th class="col-files">{{ t('vaccess.files') }}</th>
|
||||
<th class="col-paths">{{ t('vaccess.paths') }}</th>
|
||||
<th class="col-ips">{{ t('vaccess.ips') }}</th>
|
||||
<th class="col-exceptions">{{ t('vaccess.exceptions') }}</th>
|
||||
<th class="col-error">{{ t('vaccess.error') }}</th>
|
||||
<th class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(rule, index) in rules" :key="index">
|
||||
<td class="drag-handle"><i class="fas fa-grip-vertical"></i></td>
|
||||
<td>
|
||||
<VBadge :variant="rule.type === 'Allow' ? 'yes' : 'no'">{{ rule.type }}</VBadge>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="rule.type_file?.length" class="mini-tags">
|
||||
<code v-for="f in rule.type_file" :key="f">{{ f }}</code>
|
||||
</span>
|
||||
<span v-else class="empty-field">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="rule.path_access?.length" class="mini-tags">
|
||||
<code v-for="p in rule.path_access" :key="p">{{ p }}</code>
|
||||
</span>
|
||||
<span v-else class="empty-field">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="rule.ip_list?.length" class="mini-tags">
|
||||
<code v-for="ip in rule.ip_list" :key="ip">{{ ip }}</code>
|
||||
</span>
|
||||
<span v-else class="empty-field">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="rule.exceptions_dir?.length" class="mini-tags">
|
||||
<code v-for="e in rule.exceptions_dir" :key="e">{{ e }}</code>
|
||||
</span>
|
||||
<span v-else class="empty-field">—</span>
|
||||
</td>
|
||||
<td><code>{{ rule.url_error || '—' }}</code></td>
|
||||
<td class="col-actions-cell">
|
||||
<button class="icon-btn-small" @click="removeRule(index)"><i class="fas fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Вкладка: Инструкция -->
|
||||
<div v-if="activeTab === 'help'" class="vaccess-tab-content">
|
||||
<div class="vaccess-help">
|
||||
<div class="help-card">
|
||||
<h3><i class="fas fa-info-circle"></i> {{ t('vaccess.helpPrinciple') }}</h3>
|
||||
<ul>
|
||||
<li>Правила проверяются <strong>сверху вниз</strong> по порядку</li>
|
||||
<li>Первое подходящее правило срабатывает и завершает проверку</li>
|
||||
<li>Если ни одно правило не сработало — доступ <strong>разрешён</strong></li>
|
||||
<li>Перетаскивайте правила за <i class="fas fa-grip-vertical"></i> чтобы изменить порядок</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><i class="fas fa-sliders-h"></i> {{ t('vaccess.helpParams') }}</h3>
|
||||
<div class="help-params">
|
||||
<div class="help-param">
|
||||
<strong>type:</strong>
|
||||
<p><VBadge variant="yes">Allow</VBadge> (разрешить) или <VBadge variant="no">Disable</VBadge> (запретить)</p>
|
||||
</div>
|
||||
<div class="help-param">
|
||||
<strong>Расширения файлов:</strong>
|
||||
<p>Список расширений через запятую (<code>*.php</code>, <code>*.exe</code>)</p>
|
||||
</div>
|
||||
<div class="help-param">
|
||||
<strong>Пути доступа:</strong>
|
||||
<p>Список путей через запятую (<code>/admin/*</code>, <code>/api/*</code>)</p>
|
||||
</div>
|
||||
<div class="help-param">
|
||||
<strong>IP адреса:</strong>
|
||||
<p>Список IP адресов через запятую (<code>192.168.1.1</code>, <code>10.0.0.5</code>)</p>
|
||||
<p class="help-warning"><i class="fas fa-exclamation-triangle"></i> Используется реальный IP соединения (не заголовки прокси!)</p>
|
||||
</div>
|
||||
<div class="help-param">
|
||||
<strong>Исключения:</strong>
|
||||
<p>Пути-исключения через запятую (<code>/bot/*</code>, <code>/public/*</code>). Правило НЕ применяется к этим путям</p>
|
||||
</div>
|
||||
<div class="help-param">
|
||||
<strong>Страница ошибки:</strong>
|
||||
<p>Куда перенаправить при блокировке:</p>
|
||||
<ul>
|
||||
<li><code>404</code> — стандартная страница ошибки</li>
|
||||
<li><code>https://site.com</code> — внешний редирект</li>
|
||||
<li><code>/error.html</code> — локальная страница</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><i class="fas fa-search"></i> {{ t('vaccess.helpPatterns') }}</h3>
|
||||
<div class="help-patterns">
|
||||
<div class="pattern-item"><code>*.ext</code><span>Любой файл с расширением .ext</span></div>
|
||||
<div class="pattern-item"><code>no_extension</code><span>Файлы без расширения</span></div>
|
||||
<div class="pattern-item"><code>/path/*</code><span>Все файлы в папке /path/ и подпапках</span></div>
|
||||
<div class="pattern-item"><code>/file.php</code><span>Конкретный файл</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-card help-examples">
|
||||
<h3><i class="fas fa-lightbulb"></i> {{ t('vaccess.helpExamples') }}</h3>
|
||||
<div class="help-example">
|
||||
<h4>1. Запретить выполнение PHP в uploads</h4>
|
||||
<div class="example-rule">
|
||||
<div><strong>Тип:</strong> <VBadge variant="no">Disable</VBadge></div>
|
||||
<div><strong>Расширения:</strong> <code>*.php</code></div>
|
||||
<div><strong>Пути:</strong> <code>/uploads/*</code></div>
|
||||
<div><strong>Ошибка:</strong> <code>404</code></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-example">
|
||||
<h4>2. Разрешить админку только с определённых IP</h4>
|
||||
<div class="example-rule">
|
||||
<div><strong>Тип:</strong> <VBadge variant="yes">Allow</VBadge></div>
|
||||
<div><strong>Пути:</strong> <code>/admin/*</code></div>
|
||||
<div><strong>IP:</strong> <code>192.168.1.100, 127.0.0.1</code></div>
|
||||
<div><strong>Ошибка:</strong> <code>404</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.vaccess-tab {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.vaccess-tab:hover {
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vaccess-tab.active {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vaccess-tab-content {
|
||||
animation: fadeIn var(--transition-slow);
|
||||
}
|
||||
|
||||
/* Таблица правил */
|
||||
.vaccess-rules-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.vaccess-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.vaccess-table thead tr {
|
||||
background: rgba(var(--accent-rgb), 0.05);
|
||||
}
|
||||
|
||||
.vaccess-table th {
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.vaccess-table tbody tr {
|
||||
background: var(--subtle-overlay);
|
||||
transition: all var(--transition-slow);
|
||||
}
|
||||
|
||||
.vaccess-table tbody tr:hover {
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
}
|
||||
|
||||
.vaccess-table td {
|
||||
padding: 20px 16px;
|
||||
font-size: var(--text-md);
|
||||
color: var(--text-primary);
|
||||
border-top: 1px solid rgba(var(--accent-rgb), 0.05);
|
||||
border-bottom: 1px solid rgba(var(--accent-rgb), 0.05);
|
||||
}
|
||||
|
||||
.col-drag { width: 3%; min-width: 40px; text-align: center; }
|
||||
.col-type { width: 8%; min-width: 80px; }
|
||||
.col-files { width: 15%; min-width: 120px; }
|
||||
.col-paths { width: 18%; min-width: 150px; }
|
||||
.col-ips { width: 15%; min-width: 120px; }
|
||||
.col-exceptions { width: 15%; min-width: 120px; }
|
||||
.col-error { width: 10%; min-width: 100px; }
|
||||
.col-actions { width: 5%; min-width: 60px; text-align: center; }
|
||||
|
||||
.drag-handle {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.3;
|
||||
cursor: grab;
|
||||
text-align: center;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent-purple-light);
|
||||
}
|
||||
|
||||
.mini-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mini-tags code {
|
||||
padding: 2px 6px;
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--accent-purple-light);
|
||||
}
|
||||
|
||||
.empty-field {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.col-actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon-btn-small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--danger-rgb), 0.1);
|
||||
border: 1px solid rgba(var(--danger-rgb), 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent-red);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.icon-btn-small:hover {
|
||||
background: rgba(var(--danger-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* Пустое состояние */
|
||||
.vaccess-empty {
|
||||
text-align: center;
|
||||
padding: 80px 40px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: var(--space-lg);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.vaccess-empty h3 {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.vaccess-empty p {
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Help секция */
|
||||
.vaccess-help {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.help-card {
|
||||
background: var(--subtle-overlay);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-xl);
|
||||
border: 1px solid var(--glass-border);
|
||||
transition: all var(--transition-slow);
|
||||
}
|
||||
|
||||
.help-card:hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
}
|
||||
|
||||
.help-card h3 {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--accent-purple-light);
|
||||
margin: 0 0 20px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.help-card ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-card li {
|
||||
padding: 12px 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
border-bottom: 1px solid rgba(var(--accent-rgb), 0.05);
|
||||
}
|
||||
|
||||
.help-card li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.help-params {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.help-param {
|
||||
padding: 20px;
|
||||
background: rgba(var(--accent-rgb), 0.03);
|
||||
border-radius: var(--radius-lg);
|
||||
border-left: 3px solid var(--accent-purple);
|
||||
}
|
||||
|
||||
.help-param strong {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
color: var(--accent-purple-light);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.help-param p {
|
||||
margin: var(--space-sm) 0 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.help-param code {
|
||||
padding: 3px 8px;
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-base);
|
||||
color: var(--accent-purple-light);
|
||||
}
|
||||
|
||||
.help-param ul {
|
||||
margin: 12px 0 0 20px;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.help-warning {
|
||||
color: var(--warning-icon-color) !important;
|
||||
margin-top: var(--space-sm);
|
||||
font-size: var(--text-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.help-patterns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.pattern-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
background: rgba(var(--accent-rgb), 0.05);
|
||||
border-radius: 10px;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.pattern-item:hover {
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
}
|
||||
|
||||
.pattern-item code {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--accent-purple-light);
|
||||
}
|
||||
|
||||
.pattern-item span {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.help-examples {
|
||||
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.05) 0%, rgba(var(--accent-rgb), 0.03) 100%);
|
||||
}
|
||||
|
||||
.help-example {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.help-example:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.help-example h4 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.example-rule {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
background: var(--subtle-overlay);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.example-rule div {
|
||||
font-size: var(--text-md);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.example-rule strong {
|
||||
color: var(--text-muted);
|
||||
margin-right: var(--space-sm);
|
||||
}
|
||||
|
||||
.example-rule code {
|
||||
color: var(--accent-purple-light);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user