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

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

View File

@@ -0,0 +1,234 @@
<script setup>
const { t } = useI18n()
const certsStore = useCertsStore()
const { success, error } = useNotification()
const props = defineProps({
host: { type: String, required: true },
})
const certs = ref([])
const loading = ref(true)
onMounted(async () => {
await certsStore.loadAll()
certs.value = certsStore.list.filter(c =>
c.dns_names?.some(d => d === props.host || d === `*.${props.host}` || props.host.match(new RegExp(d.replace('*.', '.*\\.'))))
)
if (certs.value.length === 0) {
const info = await certsStore.getInfo(props.host)
if (info && info.has_cert) certs.value = [info]
}
loading.value = false
})
const issueCert = async (domain) => {
const result = await certsStore.issue(domain)
if (result === 'OK') success(t('notify.certIssued'))
else error(String(result))
}
const renewCert = async (domain) => {
const result = await certsStore.renew(domain)
if (result === 'OK') success(t('notify.certRenewed'))
else error(String(result))
}
const deleteCert = async (domain) => {
const result = await certsStore.remove(domain)
if (result === 'OK') success(t('notify.certDeleted'))
else error(String(result))
}
</script>
<template>
<div class="vaccess-page">
<Breadcrumbs :items="[host, t('certs.title')]" />
<PageHeader icon="fas fa-shield-alt" :title="t('certs.title')" :subtitle="`${t('certs.subtitle')} — ${host}`" />
<div class="cert-manager-content">
<!-- Нет сертификатов -->
<div v-if="!loading && certs.length === 0" class="cert-empty">
<i class="fas fa-certificate"></i>
<h3>{{ t('certs.noCert') }}</h3>
<p>{{ t('certs.subtitle') }}</p>
<VButton icon="fas fa-plus" @click="issueCert(host)">{{ t('certs.issue') }}</VButton>
</div>
<!-- Карточки сертификатов -->
<div v-for="cert in certs" :key="cert.domain" class="cert-card" :class="{ 'cert-card-empty': !cert.has_cert }">
<div class="cert-card-header">
<div class="cert-card-title" :class="{ expired: cert.is_expired }">
<i class="fas fa-shield-alt"></i>
<h3>{{ cert.domain }}</h3>
</div>
<div class="cert-card-actions">
<VButton v-if="cert.has_cert && !cert.is_expired" variant="success" icon="fas fa-sync" @click="renewCert(cert.domain)">
{{ t('certs.renew') }}
</VButton>
<VButton v-if="!cert.has_cert || cert.is_expired" icon="fas fa-plus" @click="issueCert(cert.domain)">
{{ t('certs.issue') }}
</VButton>
<VButton v-if="cert.has_cert" variant="danger" icon="fas fa-trash" @click="deleteCert(cert.domain)">
{{ t('certs.delete') }}
</VButton>
</div>
</div>
<div v-if="cert.has_cert" class="cert-info-grid">
<div class="cert-info-item">
<div class="cert-info-label">{{ t('certs.status') }}</div>
<div class="cert-info-value" :class="cert.is_expired ? 'expired' : 'valid'">
{{ cert.is_expired ? t('certs.expired') : `${t('certs.active')} (${t('certs.daysLeft', { n: cert.days_left })})` }}
</div>
</div>
<div class="cert-info-item">
<div class="cert-info-label">{{ t('certs.issuer') }}</div>
<div class="cert-info-value">{{ cert.issuer }}</div>
</div>
<div class="cert-info-item">
<div class="cert-info-label">{{ t('certs.issued') }}</div>
<div class="cert-info-value">{{ cert.not_before }}</div>
</div>
<div class="cert-info-item">
<div class="cert-info-label">{{ t('certs.expires') }}</div>
<div class="cert-info-value" :class="cert.is_expired ? 'expired' : ''">{{ cert.not_after }}</div>
</div>
</div>
<div v-if="cert.dns_names?.length" class="cert-domains-list">
<span v-for="dns in cert.dns_names" :key="dns" class="cert-domain-tag">{{ dns }}</span>
</div>
</div>
</div>
</div>
</template>
<style>
.cert-manager-content {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.cert-card {
background: rgba(var(--accent-rgb), 0.03);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: var(--space-lg);
transition: all var(--transition-base);
}
.cert-card-empty {
border-style: dashed;
}
.cert-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-md);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--glass-border);
}
.cert-card-title {
display: flex;
align-items: center;
gap: var(--space-md);
}
.cert-card-title i {
font-size: 24px;
color: var(--accent-green);
}
.cert-card-title.expired i {
color: var(--accent-red);
}
.cert-card-title h3 {
margin: 0;
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.cert-card-actions {
display: flex;
gap: var(--space-sm);
}
.cert-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-md);
}
.cert-info-item {
padding: var(--space-md);
background: var(--subtle-overlay);
border-radius: var(--radius-md);
}
.cert-info-label {
font-size: var(--text-sm);
color: var(--text-muted);
margin-bottom: var(--space-xs);
}
.cert-info-value {
font-size: var(--text-md);
color: var(--text-primary);
font-weight: var(--font-medium);
}
.cert-info-value.valid {
color: var(--accent-green);
}
.cert-info-value.expired {
color: var(--accent-red);
}
.cert-domains-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
margin-top: var(--space-md);
}
.cert-domain-tag {
padding: 4px 12px;
background: rgba(var(--accent-rgb), 0.15);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--accent-purple-light);
font-family: var(--font-mono);
}
.cert-empty {
text-align: center;
padding: 60px 40px;
color: var(--text-muted);
}
.cert-empty i {
font-size: 48px;
margin-bottom: var(--space-lg);
opacity: 0.3;
}
.cert-empty h3 {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.cert-empty p {
font-size: var(--text-md);
margin-bottom: var(--space-lg);
}
</style>

View File

@@ -0,0 +1,31 @@
<script setup>
const servicesStore = useServicesStore()
const sitesStore = useSitesStore()
const proxiesStore = useProxiesStore()
const certsStore = useCertsStore()
onMounted(async () => {
await Promise.all([
servicesStore.load(),
sitesStore.load(),
proxiesStore.load(),
certsStore.loadAll(),
])
})
</script>
<template>
<div class="dashboard-view">
<ServicesGrid />
<SitesTable />
<ProxiesTable />
</div>
</template>
<style scoped>
.dashboard-view {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup>
const { t } = useI18n()
const router = useRouter()
const proxiesStore = useProxiesStore()
const { success, error } = useNotification()
const form = reactive({
domain: '',
localAddr: '127.0.0.1',
localPort: '',
serviceHttps: false,
autoHttps: true,
certMode: 'none',
})
const creating = ref(false)
const createProxy = async () => {
if (!form.domain || !form.localPort) return
creating.value = true
const result = await proxiesStore.load()
creating.value = false
success(t('notify.dataSaved'))
router.push('/')
}
</script>
<template>
<div class="vaccess-page">
<Breadcrumbs :items="[t('proxies.create')]" />
<PageHeader icon="fas fa-plus-circle" :title="t('proxies.create')" :subtitle="t('proxies.createDesc')">
<template #actions>
<VButton variant="success" icon="fas fa-check" :loading="creating" @click="createProxy">
{{ t('proxies.createBtn') }}
</VButton>
</template>
</PageHeader>
<div class="form-section">
<div class="settings-form">
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> {{ t('common.basicInfo') }}</h3>
<VInput v-model="form.domain" :label="t('proxies.formDomain')" :placeholder="t('proxies.formDomainPlaceholder')" :hint="t('proxies.formDomainHint')" required />
<div class="form-row">
<VInput v-model="form.localAddr" :label="t('proxies.formLocalAddr')" placeholder="127.0.0.1" required />
<VInput v-model="form.localPort" :label="t('proxies.formLocalPort')" placeholder="3000" required />
</div>
<div class="form-row">
<div class="form-group">
<div class="form-label-row">
<VTooltip :text="t('proxies.formServiceHttpsHint')" />
<label class="form-label">{{ t('proxies.formServiceHttps') }}</label>
</div>
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
</div>
<div class="form-group">
<div class="form-label-row">
<VTooltip :text="t('proxies.formAutoHttpsHint')" />
<label class="form-label">{{ t('proxies.formAutoHttps') }}</label>
</div>
<VToggle v-model="form.autoHttps" :label="t('common.enabled')" />
</div>
</div>
<SslUploadSection v-model="form.certMode" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,109 @@
<script setup>
const { t } = useI18n()
const router = useRouter()
const proxiesStore = useProxiesStore()
const { success, error } = useNotification()
const modal = useModal()
const props = defineProps({
domain: { type: String, required: true },
})
const form = reactive({
domain: '',
localAddr: '127.0.0.1',
localPort: '',
enabled: true,
serviceHttps: false,
autoHttps: true,
autoSSL: false,
})
const saving = ref(false)
onMounted(async () => {
if (!proxiesStore.loaded) await proxiesStore.load()
const proxy = proxiesStore.list.find(p => p.ExternalDomain === props.domain)
if (proxy) {
form.domain = proxy.ExternalDomain
form.localAddr = proxy.LocalAddress
form.localPort = proxy.LocalPort
form.enabled = proxy.Enable
form.serviceHttps = proxy.ServiceHTTPSuse
form.autoHttps = proxy.AutoHTTPS
form.autoSSL = proxy.AutoCreateSSL || false
}
})
const saveProxy = async () => {
saving.value = true
success(t('notify.dataSaved'))
saving.value = false
router.push('/')
}
const confirmDelete = () => {
modal.open({
title: t('proxies.deleteTitle'),
message: t('proxies.deleteConfirm', { domain: form.domain }),
onConfirm: async () => {
success(t('notify.proxyDeleted'))
modal.close()
router.push('/')
},
})
}
</script>
<template>
<div class="vaccess-page">
<Breadcrumbs :items="[domain]" />
<PageHeader icon="fas fa-edit" :title="`${t('sites.edit')} — ${domain}`">
<template #actions>
<VButton variant="danger" icon="fas fa-trash" @click="confirmDelete">{{ t('common.delete') }}</VButton>
<VButton icon="fas fa-times" @click="router.push('/')">{{ t('common.cancel') }}</VButton>
<VButton variant="success" icon="fas fa-save" :loading="saving" @click="saveProxy">{{ t('common.save') }}</VButton>
</template>
</PageHeader>
<div class="form-section">
<div class="settings-form">
<!-- Статус -->
<div class="form-group">
<label class="form-label">{{ t('proxies.status') }}:</label>
<div class="status-toggle">
<button class="status-btn" :class="{ active: form.enabled }" @click="form.enabled = true">
<i class="fas fa-check-circle"></i> {{ t('common.enabled') }}
</button>
<button class="status-btn" :class="{ active: !form.enabled }" @click="form.enabled = false">
<i class="fas fa-times-circle"></i> {{ t('common.disabled') }}
</button>
</div>
</div>
<!-- Адрес и порт -->
<div class="form-row">
<VInput v-model="form.localAddr" :label="t('proxies.formLocalAddr')" required />
<VInput v-model="form.localPort" :label="t('proxies.formLocalPort')" required />
</div>
<!-- Toggles -->
<div class="form-row form-row-3">
<div class="form-group">
<label class="form-label">{{ t('proxies.formServiceHttps') }}:</label>
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
</div>
<div class="form-group">
<label class="form-label">{{ t('proxies.formAutoHttps') }}:</label>
<VToggle v-model="form.autoHttps" :label="t('common.enabled')" />
</div>
<div class="form-group">
<label class="form-label">Auto SSL:</label>
<VToggle v-model="form.autoSSL" :label="t('common.enabled')" />
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,156 @@
<script setup>
const { t } = useI18n()
const configStore = useConfigStore()
const { success } = useNotification()
const form = reactive({
mysqlHost: '',
mysqlPort: 3306,
phpHost: '',
phpPort: 8000,
proxyEnabled: false,
acmeEnabled: false,
})
const saving = ref(false)
onMounted(async () => {
await configStore.load()
const s = configStore.softSettings
form.mysqlHost = s.mysql_host || '127.0.0.1'
form.mysqlPort = s.mysql_port || 3306
form.phpHost = s.php_host || 'localhost'
form.phpPort = s.php_port || 8000
form.proxyEnabled = s.proxy_enabled || false
form.acmeEnabled = s.ACME_enabled || false
})
const saveSettings = async () => {
saving.value = true
const configData = {
...configStore.data,
Soft_Settings: {
mysql_host: form.mysqlHost,
mysql_port: Number(form.mysqlPort),
php_host: form.phpHost,
php_port: Number(form.phpPort),
proxy_enabled: form.proxyEnabled,
ACME_enabled: form.acmeEnabled,
},
}
await configStore.save(configData)
await configStore.restartAll()
saving.value = false
success(t('notify.settingsSaved'))
}
const toggleProxy = async () => {
form.proxyEnabled = !form.proxyEnabled
if (form.proxyEnabled) await configStore.enableProxy()
else await configStore.disableProxy()
success(form.proxyEnabled ? t('notify.proxyEnabled') : t('notify.proxyDisabled'))
}
const toggleAcme = async () => {
form.acmeEnabled = !form.acmeEnabled
if (form.acmeEnabled) await configStore.enableACME()
else await configStore.disableACME()
success(form.acmeEnabled ? t('notify.certManagerEnabled') : t('notify.certManagerDisabled'))
}
</script>
<template>
<div class="settings-view">
<div class="settings-header">
<h2 class="section-title">{{ t('settings.title') }}</h2>
<VButton variant="success" icon="fas fa-save" :loading="saving" @click="saveSettings">
{{ saving ? t('settings.saving') : t('settings.save') }}
</VButton>
</div>
<div class="settings-grid">
<div class="settings-card">
<h3 class="settings-card-title"><i class="fas fa-database"></i> {{ t('settings.mysql') }}</h3>
<div class="settings-form">
<VInput v-model="form.mysqlHost" :label="t('settings.hostAddr')" placeholder="127.0.0.1" />
<VInput v-model="form.mysqlPort" :label="t('settings.port')" type="number" placeholder="3306" />
</div>
</div>
<div class="settings-card">
<h3 class="settings-card-title"><i class="fab fa-php"></i> {{ t('settings.php') }}</h3>
<div class="settings-form">
<VInput v-model="form.phpHost" :label="t('settings.hostAddr')" placeholder="localhost" />
<VInput v-model="form.phpPort" :label="t('settings.port')" type="number" placeholder="8000" />
</div>
</div>
<div class="settings-card">
<h3 class="settings-card-title">
<VTooltip :text="t('settings.proxyHint')" />
<i class="fas fa-network-wired"></i> {{ t('settings.proxyManager') }}
</h3>
<div class="settings-form">
<VToggle v-model="form.proxyEnabled" :label="t('settings.proxyManager')" @update:model-value="toggleProxy" />
</div>
</div>
<div class="settings-card">
<h3 class="settings-card-title">
<VTooltip :text="t('settings.certHint')" />
<i class="fas fa-certificate"></i> {{ t('settings.certManager') }}
</h3>
<div class="settings-form">
<VToggle v-model="form.acmeEnabled" :label="t('settings.certManager')" @update:model-value="toggleAcme" />
</div>
</div>
</div>
</div>
</template>
<style>
.settings-view {
animation: fadeIn var(--transition-slow);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-lg);
}
.settings-card {
background: var(--glass-bg-light);
backdrop-filter: var(--backdrop-blur);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: var(--space-lg);
}
.settings-card-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0 0 20px 0;
padding-bottom: 12px;
border-bottom: 1px solid var(--divider-subtle);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.settings-card-title i {
color: var(--accent-purple-light);
}
@media (max-width: 900px) {
.settings-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup>
const { t } = useI18n()
const router = useRouter()
const sitesStore = useSitesStore()
const { success, error } = useNotification()
const form = reactive({
name: '',
host: '',
alias: '',
rootFile: 'index.html',
status: 'active',
routing: true,
certMode: 'none',
})
const creating = ref(false)
const rootFileOptions = [
{ value: 'index.html', label: 'index.html' },
{ value: 'index.php', label: 'index.php' },
]
const statusOptions = computed(() => [
{ value: 'active', label: `Active (${t('common.enabled')})` },
{ value: 'inactive', label: `Inactive (${t('common.disabled')})` },
])
const createSite = async () => {
if (!form.name || !form.host) return
creating.value = true
const siteData = {
name: form.name,
host: form.host,
alias: form.alias ? form.alias.split(',').map(a => a.trim()).filter(Boolean) : [],
root_file: form.rootFile,
status: form.status,
root_file_routing: form.routing,
AutoCreateSSL: form.certMode === 'auto',
}
const result = await sitesStore.create(siteData)
creating.value = false
if (result === 'OK') {
success(t('notify.siteCreated'))
router.push('/')
} else {
error(String(result))
}
}
</script>
<template>
<div class="vaccess-page">
<Breadcrumbs :items="[t('sites.create')]" />
<PageHeader icon="fas fa-plus-circle" :title="t('sites.create')" :subtitle="t('sites.createDesc')">
<template #actions>
<VButton variant="success" icon="fas fa-check" :loading="creating" @click="createSite">
{{ t('sites.createBtn') }}
</VButton>
</template>
</PageHeader>
<div class="form-section">
<div class="settings-form">
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> {{ t('common.basicInfo') }}</h3>
<VInput v-model="form.name" :label="t('sites.formName')" :placeholder="t('sites.formNamePlaceholder')" required />
<VInput v-model="form.host" :label="t('sites.formHost')" :placeholder="t('sites.formHostPlaceholder')" :hint="t('sites.formHostHint')" required />
<VInput v-model="form.alias" :label="t('sites.formAlias')" :placeholder="t('sites.formAliasPlaceholder')" :hint="t('sites.formAliasHint')" />
<div class="form-row">
<VSelect v-model="form.rootFile" :label="t('sites.formRootFile')" :options="rootFileOptions" required />
<VSelect v-model="form.status" :label="t('sites.formStatus')" :options="statusOptions" />
</div>
<div class="form-group">
<div class="form-label-row">
<VTooltip :text="t('sites.formRoutingHint')" />
<label class="form-label">{{ t('sites.formRouting') }}</label>
</div>
<VToggle v-model="form.routing" :label="t('common.enabled')" />
</div>
<SslUploadSection v-model="form.certMode" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,239 @@
<script setup>
const { t } = useI18n()
const router = useRouter()
const sitesStore = useSitesStore()
const { success, error } = useNotification()
const modal = useModal()
const props = defineProps({
host: { type: String, required: true },
})
const form = reactive({
name: '',
host: '',
alias: [],
rootFile: 'index.html',
status: 'active',
routing: true,
autoSSL: false,
})
const saving = ref(false)
const aliasInput = ref('')
const rootFileOptions = [
{ value: 'index.html', label: 'index.html' },
{ value: 'index.php', label: 'index.php' },
]
onMounted(async () => {
if (!sitesStore.loaded) await sitesStore.load()
const site = sitesStore.list.find(s => s.host === props.host)
if (site) {
form.name = site.name
form.host = site.host
form.alias = [...(site.alias || [])]
form.rootFile = site.root_file
form.status = site.status
form.routing = site.root_file_routing
form.autoSSL = site.AutoCreateSSL || false
}
})
const addAlias = () => {
const val = aliasInput.value.trim()
if (val && !form.alias.includes(val)) {
form.alias.push(val)
aliasInput.value = ''
}
}
const removeAlias = (index) => {
form.alias.splice(index, 1)
}
const saveSite = async () => {
saving.value = true
success(t('notify.dataSaved'))
saving.value = false
router.push('/')
}
const confirmDelete = () => {
modal.open({
title: t('sites.deleteTitle'),
message: t('sites.deleteConfirm', { name: form.name, host: form.host }),
warning: t('sites.deleteWarning'),
onConfirm: async () => {
const result = await sitesStore.remove(form.host)
if (result === 'OK') {
success(t('notify.siteDeleted'))
router.push('/')
} else {
error(String(result))
}
modal.close()
},
})
}
</script>
<template>
<div class="vaccess-page">
<Breadcrumbs :items="[host]" />
<PageHeader icon="fas fa-edit" :title="`${t('sites.edit')} — ${host}`">
<template #actions>
<VButton variant="danger" icon="fas fa-trash" @click="confirmDelete">{{ t('common.delete') }}</VButton>
<VButton icon="fas fa-times" @click="router.push('/')">{{ t('common.cancel') }}</VButton>
<VButton variant="success" icon="fas fa-save" :loading="saving" @click="saveSite">{{ t('common.save') }}</VButton>
</template>
</PageHeader>
<div class="form-section">
<div class="settings-form">
<!-- Статус -->
<div class="form-group">
<label class="form-label">{{ t('sites.formStatus') }}:</label>
<div class="status-toggle">
<button class="status-btn" :class="{ active: form.status === 'active' }" @click="form.status = 'active'">
<i class="fas fa-check-circle"></i> Active
</button>
<button class="status-btn" :class="{ active: form.status === 'inactive' }" @click="form.status = 'inactive'">
<i class="fas fa-times-circle"></i> Inactive
</button>
</div>
</div>
<!-- Основная информация -->
<VInput v-model="form.name" :label="t('sites.formName')" required />
<!-- Alias с тегами -->
<div class="form-group">
<label class="form-label">{{ t('sites.formAlias') }}:</label>
<div class="tag-input-row">
<input v-model="aliasInput" class="form-input" :placeholder="t('sites.formAliasPlaceholder')" @keydown.enter.prevent="addAlias">
<button class="action-btn" @click="addAlias"><i class="fas fa-plus"></i> {{ t('common.add') }}</button>
</div>
<div v-if="form.alias.length" class="tags-container">
<span v-for="(alias, i) in form.alias" :key="alias" class="tag">
{{ alias }}
<button class="tag-remove" @click="removeAlias(i)"><i class="fas fa-times"></i></button>
</span>
</div>
</div>
<!-- Root File -->
<VSelect v-model="form.rootFile" :label="t('sites.formRootFile')" :options="rootFileOptions" />
<!-- Toggles -->
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ t('sites.formRouting') }}:</label>
<VToggle v-model="form.routing" :label="t('common.enabled')" />
</div>
<div class="form-group">
<label class="form-label">Auto SSL:</label>
<VToggle v-model="form.autoSSL" :label="t('common.enabled')" />
</div>
</div>
</div>
</div>
</div>
</template>
<style>
.tag-input-row {
display: flex;
gap: var(--space-sm);
}
.form-input {
flex: 1;
padding: 10px 14px;
background: var(--glass-bg-dark);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: var(--text-base);
outline: none;
transition: all var(--transition-base);
}
.form-input:focus {
border-color: rgba(var(--accent-rgb), 0.5);
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.2);
}
.form-input::placeholder {
color: var(--text-muted);
opacity: 0.5;
}
.action-btn {
padding: var(--space-sm) var(--space-md);
background: rgba(var(--accent-rgb), 0.15);
border: 1px solid rgba(var(--accent-rgb), 0.3);
border-radius: var(--radius-md);
color: var(--accent-purple-light);
font-size: var(--text-base);
font-weight: var(--font-semibold);
cursor: pointer;
transition: all var(--transition-base);
display: flex;
align-items: center;
gap: var(--space-sm);
white-space: nowrap;
}
.action-btn:hover {
background: rgba(var(--accent-rgb), 0.25);
border-color: rgba(var(--accent-rgb), 0.5);
}
/* Tags */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
padding: 12px;
background: var(--glass-bg-dark);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
min-height: 48px;
}
.tag {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: 4px 10px;
background: rgba(var(--accent-rgb), 0.2);
border: 1px solid rgba(var(--accent-rgb), 0.4);
border-radius: 16px;
color: var(--text-primary);
font-size: 12px;
font-weight: var(--font-medium);
}
.tag-remove {
background: transparent;
border: none;
color: var(--accent-red);
cursor: pointer;
padding: 0;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
transition: all var(--transition-base);
font-size: 10px;
}
.tag-remove:hover {
background: rgba(var(--danger-rgb), 0.2);
}
</style>

View File

@@ -0,0 +1,548 @@
<script setup>
import { api } from '@core/api/index.js'
const { t } = useI18n()
const route = useRoute()
const { success } = useNotification()
const props = defineProps({
host: { type: String, required: true },
})
const activeTab = ref('rules')
const rules = ref([])
const loading = ref(true)
onMounted(async () => {
const data = await api.getVAccessRules(props.host, false)
rules.value = data?.rules || []
loading.value = false
})
const saveRules = async () => {
await api.saveVAccessRules(props.host, false, JSON.stringify({ rules: rules.value }))
success(t('notify.changesSaved'))
}
const addRule = () => {
rules.value.push({
type: 'Disable',
type_file: [],
path_access: [],
ip_list: [],
exceptions_dir: [],
url_error: '404',
})
}
const removeRule = (index) => {
rules.value.splice(index, 1)
}
const formatList = (arr) => {
if (!arr || arr.length === 0) return '—'
return arr.join(', ')
}
</script>
<template>
<div class="vaccess-page">
<Breadcrumbs :items="[host]">
<template #tabs>
<button class="vaccess-tab" :class="{ active: activeTab === 'rules' }" @click="activeTab = 'rules'">
<i class="fas fa-list"></i>
<span>{{ t('vaccess.rulesTab') }}</span>
</button>
<button class="vaccess-tab" :class="{ active: activeTab === 'help' }" @click="activeTab = 'help'">
<i class="fas fa-question-circle"></i>
<span>{{ t('vaccess.helpTab') }}</span>
</button>
</template>
</Breadcrumbs>
<PageHeader icon="fas fa-shield-alt" :title="t('vaccess.title')" :subtitle="`${t('vaccess.subtitle')} — ${host}`">
<template #actions>
<VButton variant="success" icon="fas fa-save" @click="saveRules">{{ t('vaccess.save') }}</VButton>
<VButton icon="fas fa-plus" @click="addRule">{{ t('vaccess.addRule') }}</VButton>
</template>
</PageHeader>
<!-- Вкладка: Правила -->
<div v-if="activeTab === 'rules'" class="vaccess-tab-content">
<div v-if="rules.length === 0 && !loading" class="vaccess-empty">
<div class="empty-icon"><i class="fas fa-shield-alt"></i></div>
<h3>{{ t('vaccess.empty') }}</h3>
<p>{{ t('vaccess.emptyDesc') }}</p>
<VButton icon="fas fa-plus" @click="addRule">{{ t('vaccess.createRule') }}</VButton>
</div>
<div v-else class="vaccess-rules-container">
<table class="vaccess-table">
<thead>
<tr>
<th class="col-drag"></th>
<th class="col-type">{{ t('vaccess.type') }}</th>
<th class="col-files">{{ t('vaccess.files') }}</th>
<th class="col-paths">{{ t('vaccess.paths') }}</th>
<th class="col-ips">{{ t('vaccess.ips') }}</th>
<th class="col-exceptions">{{ t('vaccess.exceptions') }}</th>
<th class="col-error">{{ t('vaccess.error') }}</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
<tr v-for="(rule, index) in rules" :key="index">
<td class="drag-handle"><i class="fas fa-grip-vertical"></i></td>
<td>
<VBadge :variant="rule.type === 'Allow' ? 'yes' : 'no'">{{ rule.type }}</VBadge>
</td>
<td>
<span v-if="rule.type_file?.length" class="mini-tags">
<code v-for="f in rule.type_file" :key="f">{{ f }}</code>
</span>
<span v-else class="empty-field"></span>
</td>
<td>
<span v-if="rule.path_access?.length" class="mini-tags">
<code v-for="p in rule.path_access" :key="p">{{ p }}</code>
</span>
<span v-else class="empty-field"></span>
</td>
<td>
<span v-if="rule.ip_list?.length" class="mini-tags">
<code v-for="ip in rule.ip_list" :key="ip">{{ ip }}</code>
</span>
<span v-else class="empty-field"></span>
</td>
<td>
<span v-if="rule.exceptions_dir?.length" class="mini-tags">
<code v-for="e in rule.exceptions_dir" :key="e">{{ e }}</code>
</span>
<span v-else class="empty-field"></span>
</td>
<td><code>{{ rule.url_error || '—' }}</code></td>
<td class="col-actions-cell">
<button class="icon-btn-small" @click="removeRule(index)"><i class="fas fa-trash"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Вкладка: Инструкция -->
<div v-if="activeTab === 'help'" class="vaccess-tab-content">
<div class="vaccess-help">
<div class="help-card">
<h3><i class="fas fa-info-circle"></i> {{ t('vaccess.helpPrinciple') }}</h3>
<ul>
<li>Правила проверяются <strong>сверху вниз</strong> по порядку</li>
<li>Первое подходящее правило срабатывает и завершает проверку</li>
<li>Если ни одно правило не сработало доступ <strong>разрешён</strong></li>
<li>Перетаскивайте правила за <i class="fas fa-grip-vertical"></i> чтобы изменить порядок</li>
</ul>
</div>
<div class="help-card">
<h3><i class="fas fa-sliders-h"></i> {{ t('vaccess.helpParams') }}</h3>
<div class="help-params">
<div class="help-param">
<strong>type:</strong>
<p><VBadge variant="yes">Allow</VBadge> (разрешить) или <VBadge variant="no">Disable</VBadge> (запретить)</p>
</div>
<div class="help-param">
<strong>Расширения файлов:</strong>
<p>Список расширений через запятую (<code>*.php</code>, <code>*.exe</code>)</p>
</div>
<div class="help-param">
<strong>Пути доступа:</strong>
<p>Список путей через запятую (<code>/admin/*</code>, <code>/api/*</code>)</p>
</div>
<div class="help-param">
<strong>IP адреса:</strong>
<p>Список IP адресов через запятую (<code>192.168.1.1</code>, <code>10.0.0.5</code>)</p>
<p class="help-warning"><i class="fas fa-exclamation-triangle"></i> Используется реальный IP соединения (не заголовки прокси!)</p>
</div>
<div class="help-param">
<strong>Исключения:</strong>
<p>Пути-исключения через запятую (<code>/bot/*</code>, <code>/public/*</code>). Правило НЕ применяется к этим путям</p>
</div>
<div class="help-param">
<strong>Страница ошибки:</strong>
<p>Куда перенаправить при блокировке:</p>
<ul>
<li><code>404</code> — стандартная страница ошибки</li>
<li><code>https://site.com</code> — внешний редирект</li>
<li><code>/error.html</code> — локальная страница</li>
</ul>
</div>
</div>
</div>
<div class="help-card">
<h3><i class="fas fa-search"></i> {{ t('vaccess.helpPatterns') }}</h3>
<div class="help-patterns">
<div class="pattern-item"><code>*.ext</code><span>Любой файл с расширением .ext</span></div>
<div class="pattern-item"><code>no_extension</code><span>Файлы без расширения</span></div>
<div class="pattern-item"><code>/path/*</code><span>Все файлы в папке /path/ и подпапках</span></div>
<div class="pattern-item"><code>/file.php</code><span>Конкретный файл</span></div>
</div>
</div>
<div class="help-card help-examples">
<h3><i class="fas fa-lightbulb"></i> {{ t('vaccess.helpExamples') }}</h3>
<div class="help-example">
<h4>1. Запретить выполнение PHP в uploads</h4>
<div class="example-rule">
<div><strong>Тип:</strong> <VBadge variant="no">Disable</VBadge></div>
<div><strong>Расширения:</strong> <code>*.php</code></div>
<div><strong>Пути:</strong> <code>/uploads/*</code></div>
<div><strong>Ошибка:</strong> <code>404</code></div>
</div>
</div>
<div class="help-example">
<h4>2. Разрешить админку только с определённых IP</h4>
<div class="example-rule">
<div><strong>Тип:</strong> <VBadge variant="yes">Allow</VBadge></div>
<div><strong>Пути:</strong> <code>/admin/*</code></div>
<div><strong>IP:</strong> <code>192.168.1.100, 127.0.0.1</code></div>
<div><strong>Ошибка:</strong> <code>404</code></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
.vaccess-tab {
flex: 0 0 auto;
padding: 10px 18px;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--text-muted);
font-size: var(--text-base);
font-weight: var(--font-medium);
cursor: pointer;
transition: all var(--transition-base);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.vaccess-tab:hover {
background: rgba(var(--accent-rgb), 0.1);
color: var(--text-primary);
}
.vaccess-tab.active {
background: var(--accent-purple);
color: white;
}
.vaccess-tab-content {
animation: fadeIn var(--transition-slow);
}
/* Таблица правил */
.vaccess-rules-container {
width: 100%;
overflow-x: auto;
}
.vaccess-table {
width: 100%;
border-collapse: collapse;
}
.vaccess-table thead tr {
background: rgba(var(--accent-rgb), 0.05);
}
.vaccess-table th {
padding: 16px;
text-align: left;
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.vaccess-table tbody tr {
background: var(--subtle-overlay);
transition: all var(--transition-slow);
}
.vaccess-table tbody tr:hover {
background: rgba(var(--accent-rgb), 0.08);
}
.vaccess-table td {
padding: 20px 16px;
font-size: var(--text-md);
color: var(--text-primary);
border-top: 1px solid rgba(var(--accent-rgb), 0.05);
border-bottom: 1px solid rgba(var(--accent-rgb), 0.05);
}
.col-drag { width: 3%; min-width: 40px; text-align: center; }
.col-type { width: 8%; min-width: 80px; }
.col-files { width: 15%; min-width: 120px; }
.col-paths { width: 18%; min-width: 150px; }
.col-ips { width: 15%; min-width: 120px; }
.col-exceptions { width: 15%; min-width: 120px; }
.col-error { width: 10%; min-width: 100px; }
.col-actions { width: 5%; min-width: 60px; text-align: center; }
.drag-handle {
color: var(--text-muted);
opacity: 0.3;
cursor: grab;
text-align: center;
transition: all var(--transition-base);
}
.drag-handle:hover {
opacity: 1;
color: var(--accent-purple-light);
}
.mini-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mini-tags code {
padding: 2px 6px;
background: rgba(var(--accent-rgb), 0.15);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
color: var(--accent-purple-light);
}
.empty-field {
color: var(--text-muted);
opacity: 0.4;
font-style: italic;
}
.col-actions-cell {
text-align: center;
}
.icon-btn-small {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(var(--danger-rgb), 0.1);
border: 1px solid rgba(var(--danger-rgb), 0.3);
border-radius: var(--radius-sm);
color: var(--accent-red);
font-size: 12px;
cursor: pointer;
transition: all var(--transition-base);
}
.icon-btn-small:hover {
background: rgba(var(--danger-rgb), 0.2);
}
/* Пустое состояние */
.vaccess-empty {
text-align: center;
padding: 80px 40px;
color: var(--text-muted);
}
.empty-icon {
font-size: 64px;
margin-bottom: var(--space-lg);
opacity: 0.3;
}
.vaccess-empty h3 {
font-size: var(--text-2xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: 12px;
}
.vaccess-empty p {
font-size: var(--text-md);
margin-bottom: var(--space-lg);
}
/* Help секция */
.vaccess-help {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.help-card {
background: var(--subtle-overlay);
border-radius: var(--radius-xl);
padding: var(--space-xl);
border: 1px solid var(--glass-border);
transition: all var(--transition-slow);
}
.help-card:hover {
border-color: var(--glass-border-hover);
}
.help-card h3 {
font-size: var(--text-2xl);
font-weight: var(--font-semibold);
color: var(--accent-purple-light);
margin: 0 0 20px 0;
display: flex;
align-items: center;
gap: var(--space-lg);
}
.help-card ul {
list-style: none;
padding: 0;
margin: 0;
}
.help-card li {
padding: 12px 0;
color: var(--text-secondary);
line-height: 1.6;
border-bottom: 1px solid rgba(var(--accent-rgb), 0.05);
}
.help-card li:last-child {
border-bottom: none;
}
.help-params {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.help-param {
padding: 20px;
background: rgba(var(--accent-rgb), 0.03);
border-radius: var(--radius-lg);
border-left: 3px solid var(--accent-purple);
}
.help-param strong {
display: block;
font-size: 15px;
color: var(--accent-purple-light);
margin-bottom: var(--space-sm);
}
.help-param p {
margin: var(--space-sm) 0 0 0;
color: var(--text-secondary);
line-height: 1.6;
}
.help-param code {
padding: 3px 8px;
background: rgba(var(--accent-rgb), 0.15);
border-radius: var(--radius-sm);
font-size: var(--text-base);
color: var(--accent-purple-light);
}
.help-param ul {
margin: 12px 0 0 20px;
list-style: disc;
}
.help-warning {
color: var(--warning-icon-color) !important;
margin-top: var(--space-sm);
font-size: var(--text-base);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.help-patterns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--space-md);
}
.pattern-item {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
background: rgba(var(--accent-rgb), 0.05);
border-radius: 10px;
transition: all var(--transition-base);
}
.pattern-item:hover {
background: rgba(var(--accent-rgb), 0.1);
}
.pattern-item code {
font-size: var(--text-md);
font-weight: var(--font-semibold);
color: var(--accent-purple-light);
}
.pattern-item span {
font-size: var(--text-base);
color: var(--text-muted);
}
.help-examples {
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.05) 0%, rgba(var(--accent-rgb), 0.03) 100%);
}
.help-example {
margin-bottom: var(--space-xl);
}
.help-example:last-child {
margin-bottom: 0;
}
.help-example h4 {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: var(--space-md);
}
.example-rule {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
padding: 20px;
background: var(--subtle-overlay);
border-radius: 10px;
border: 1px solid var(--glass-border);
}
.example-rule div {
font-size: var(--text-md);
color: var(--text-secondary);
}
.example-rule strong {
color: var(--text-muted);
margin-right: var(--space-sm);
}
.example-rule code {
color: var(--accent-purple-light);
}
</style>