Большое обновление GUI интерфейс

Большое обновление GUI интерфейс

- Добавлен фраемворr Walles
- Удалена консольная версия
- Проработан интерфейс и дизайн
- Добавлено кеширование для быстрой реакции.
- Сделан .ps1 сборщик для удобной сборки проекта.
- Обновлён Readme
This commit is contained in:
2025-11-14 08:40:25 +07:00
parent 752f294392
commit 02ae56b78c
93 changed files with 7477 additions and 3504 deletions

View File

@@ -0,0 +1,151 @@
/* ============================================
Proxy Component
Управление прокси
============================================ */
import { api } from '../api/wails.js';
import { isWailsAvailable } from '../utils/helpers.js';
import { $ } from '../utils/dom.js';
/**
* Класс для управления прокси
*/
export class ProxyManager {
constructor() {
this.proxiesData = [];
this.mockData = [
{
enable: true,
external_domain: 'git.example.ru',
local_address: '127.0.0.1',
local_port: '3333',
service_https_use: false,
auto_https: true,
status: 'active'
},
{
enable: true,
external_domain: 'api.example.com',
local_address: '127.0.0.1',
local_port: '8080',
service_https_use: true,
auto_https: false,
status: 'active'
},
{
enable: false,
external_domain: 'test.example.net',
local_address: '127.0.0.1',
local_port: '5000',
service_https_use: false,
auto_https: false,
status: 'disabled'
}
];
}
/**
* Загрузить список прокси
*/
async load() {
if (isWailsAvailable()) {
this.proxiesData = await api.getProxyList();
} else {
// Используем тестовые данные если Wails недоступен
this.proxiesData = this.mockData;
}
this.render();
}
/**
* Отрисовать список прокси
*/
render() {
const tbody = $('proxyTable')?.querySelector('tbody');
if (!tbody) return;
tbody.innerHTML = '';
this.proxiesData.forEach((proxy, index) => {
const row = document.createElement('tr');
const statusBadge = proxy.status === 'active' ? 'badge-online' : 'badge-offline';
const httpsBadge = proxy.service_https_use ? 'badge-yes">HTTPS' : 'badge-no">HTTP';
const autoHttpsBadge = proxy.auto_https ? 'badge-yes">Да' : 'badge-no">Нет';
const protocol = proxy.auto_https ? 'https' : 'http';
row.innerHTML = `
<td><code class="clickable-link" data-url="${protocol}://${proxy.external_domain}">${proxy.external_domain} <i class="fas fa-external-link-alt"></i></code></td>
<td><code>${proxy.local_address}:${proxy.local_port}</code></td>
<td><span class="badge ${httpsBadge}</span></td>
<td><span class="badge ${autoHttpsBadge}</span></td>
<td><span class="badge ${statusBadge}">${proxy.status}</span></td>
<td>
<button class="icon-btn" data-action="edit-vaccess" data-host="${proxy.external_domain}" data-is-proxy="true" title="vAccess"><i class="fas fa-shield-alt"></i></button>
<button class="icon-btn" data-action="edit-proxy" data-index="${index}" title="Редактировать"><i class="fas fa-edit"></i></button>
</td>
`;
tbody.appendChild(row);
});
// Добавляем обработчики событий
this.attachEventListeners();
}
/**
* Добавить обработчики событий
*/
attachEventListeners() {
// Кликабельные ссылки
const links = document.querySelectorAll('.clickable-link[data-url]');
links.forEach(link => {
link.addEventListener('click', () => {
const url = link.getAttribute('data-url');
this.openLink(url);
});
});
// Кнопки действий
const buttons = document.querySelectorAll('[data-action]');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.getAttribute('data-action');
this.handleAction(action, btn);
});
});
}
/**
* Обработчик действий
*/
handleAction(action, btn) {
const host = btn.getAttribute('data-host');
const index = parseInt(btn.getAttribute('data-index'));
const isProxy = btn.getAttribute('data-is-proxy') === 'true';
switch (action) {
case 'edit-vaccess':
if (window.editVAccess) {
window.editVAccess(host, isProxy);
}
break;
case 'edit-proxy':
if (window.editProxy) {
window.editProxy(index);
}
break;
}
}
/**
* Открыть ссылку
*/
openLink(url) {
if (window.runtime?.BrowserOpenURL) {
window.runtime.BrowserOpenURL(url);
} else {
window.open(url, '_blank');
}
}
}

View File

@@ -0,0 +1,191 @@
/* ============================================
Services Component
Управление сервисами
============================================ */
import { api } from '../api/wails.js';
import { $, $$, addClass, removeClass } from '../utils/dom.js';
import { notification } from '../ui/notification.js';
import { sleep, isWailsAvailable } from '../utils/helpers.js';
/**
* Класс для управления сервисами
*/
export class ServicesManager {
constructor() {
this.serverRunning = true;
this.isOperating = false;
this.controlBtn = $('serverControlBtn');
this.statusIndicator = document.querySelector('.status-indicator');
this.statusText = document.querySelector('.status-text');
this.btnText = document.querySelector('.btn-text');
this.init();
}
init() {
if (this.controlBtn) {
this.controlBtn.addEventListener('click', () => this.toggleServer());
}
// Подписка на события
if (window.runtime?.EventsOn) {
window.runtime.EventsOn('service:changed', (status) => {
this.renderServices(status);
});
window.runtime.EventsOn('server:already_running', () => {
notification.error('vServer уже запущен!<br><br>Закройте другой экземпляр перед запуском нового.', 5000);
this.setServerStatus(false, 'Уже запущен в другом процессе');
});
}
}
/**
* Переключить состояние сервера
*/
async toggleServer() {
if (this.serverRunning) {
await this.stopServer();
} else {
await this.startServer();
}
}
/**
* Запустить сервер
*/
async startServer() {
this.isOperating = true;
this.controlBtn.disabled = true;
this.statusText.textContent = 'Запускается...';
this.btnText.textContent = 'Ожидайте...';
this.setAllServicesPending('Запуск');
await api.startServer();
// Ждём пока все запустятся
let attempts = 0;
while (attempts < 20) {
await sleep(500);
if (await api.checkServicesReady()) {
break;
}
attempts++;
}
this.isOperating = false;
this.setServerStatus(true, 'Сервер запущен');
removeClass(this.controlBtn, 'start-mode');
this.btnText.textContent = 'Остановить';
this.controlBtn.disabled = false;
}
/**
* Остановить сервер
*/
async stopServer() {
this.isOperating = true;
this.controlBtn.disabled = true;
this.statusText.textContent = 'Выключается...';
this.btnText.textContent = 'Ожидайте...';
this.setAllServicesPending('Остановка');
await api.stopServer();
await sleep(1500);
this.isOperating = false;
this.setServerStatus(false, 'Сервер остановлен');
addClass(this.controlBtn, 'start-mode');
this.btnText.textContent = 'Запустить';
this.controlBtn.disabled = false;
}
/**
* Установить статус сервера
*/
setServerStatus(isOnline, text) {
this.serverRunning = isOnline;
if (isOnline) {
removeClass(this.statusIndicator, 'status-offline');
addClass(this.statusIndicator, 'status-online');
} else {
removeClass(this.statusIndicator, 'status-online');
addClass(this.statusIndicator, 'status-offline');
}
this.statusText.textContent = text;
}
/**
* Установить всем сервисам статус pending
*/
setAllServicesPending(text) {
const badges = $$('.service-card .badge');
badges.forEach(badge => {
badge.className = 'badge badge-pending';
badge.textContent = text;
});
}
/**
* Отрисовать статусы сервисов
*/
renderServices(data) {
const services = [data.http, data.https, data.mysql, data.php, data.proxy];
const cards = $$('.service-card');
services.forEach((service, index) => {
const card = cards[index];
if (!card) return;
const badge = card.querySelector('.badge');
const infoValues = card.querySelectorAll('.info-value');
// Обновляем badge только если НЕ в процессе операции
if (badge && !this.isOperating) {
if (service.status) {
badge.className = 'badge badge-online';
badge.textContent = 'Активен';
} else {
badge.className = 'badge badge-offline';
badge.textContent = 'Остановлен';
}
}
// Обновляем значения
if (service.name === 'Proxy') {
if (infoValues[0] && service.info) {
infoValues[0].textContent = service.info;
}
} else {
if (infoValues[0]) {
infoValues[0].textContent = service.port;
}
}
});
}
/**
* Загрузить статусы сервисов
*/
async loadStatus() {
if (isWailsAvailable()) {
const data = await api.getAllServicesStatus();
if (data) {
this.renderServices(data);
}
} else {
// Используем тестовые данные если Wails недоступен
const mockData = {
http: { name: 'HTTP', status: true, port: '80' },
https: { name: 'HTTPS', status: true, port: '443' },
mysql: { name: 'MySQL', status: true, port: '3306' },
php: { name: 'PHP', status: true, port: '8000-8003' },
proxy: { name: 'Proxy', status: true, port: '', info: '1 из 3' }
};
this.renderServices(mockData);
}
}
}

View File

@@ -0,0 +1,150 @@
/* ============================================
Sites Component
Управление сайтами
============================================ */
import { api } from '../api/wails.js';
import { isWailsAvailable } from '../utils/helpers.js';
import { $ } from '../utils/dom.js';
/**
* Класс для управления сайтами
*/
export class SitesManager {
constructor() {
this.sitesData = [];
this.mockData = [
{
name: 'Локальный сайт',
host: '127.0.0.1',
alias: ['localhost'],
status: 'active',
root_file: 'index.html',
root_file_routing: true
},
{
name: 'Тестовый проект',
host: 'test.local',
alias: ['*.test.local', 'test.com'],
status: 'active',
root_file: 'index.php',
root_file_routing: false
},
{
name: 'API сервис',
host: 'api.example.com',
alias: ['*.api.example.com'],
status: 'inactive',
root_file: 'index.php',
root_file_routing: true
}
];
}
/**
* Загрузить список сайтов
*/
async load() {
if (isWailsAvailable()) {
this.sitesData = await api.getSitesList();
} else {
// Используем тестовые данные если Wails недоступен
this.sitesData = this.mockData;
}
this.render();
}
/**
* Отрисовать список сайтов
*/
render() {
const tbody = $('sitesTable')?.querySelector('tbody');
if (!tbody) return;
tbody.innerHTML = '';
this.sitesData.forEach((site, index) => {
const row = document.createElement('tr');
const statusBadge = site.status === 'active' ? 'badge-online' : 'badge-offline';
const aliases = site.alias.join(', ');
row.innerHTML = `
<td>${site.name}</td>
<td><code class="clickable-link" data-url="http://${site.host}">${site.host} <i class="fas fa-external-link-alt"></i></code></td>
<td><code>${aliases}</code></td>
<td><span class="badge ${statusBadge}">${site.status}</span></td>
<td><code>${site.root_file}</code></td>
<td>
<button class="icon-btn" data-action="open-folder" data-host="${site.host}" title="Открыть папку"><i class="fas fa-folder-open"></i></button>
<button class="icon-btn" data-action="edit-vaccess" data-host="${site.host}" data-is-proxy="false" title="vAccess"><i class="fas fa-shield-alt"></i></button>
<button class="icon-btn" data-action="edit-site" data-index="${index}" title="Редактировать"><i class="fas fa-edit"></i></button>
</td>
`;
tbody.appendChild(row);
});
// Добавляем обработчики событий
this.attachEventListeners();
}
/**
* Добавить обработчики событий
*/
attachEventListeners() {
// Кликабельные ссылки
const links = document.querySelectorAll('.clickable-link[data-url]');
links.forEach(link => {
link.addEventListener('click', () => {
const url = link.getAttribute('data-url');
this.openLink(url);
});
});
// Кнопки действий
const buttons = document.querySelectorAll('[data-action]');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.getAttribute('data-action');
this.handleAction(action, btn);
});
});
}
/**
* Обработчик действий
*/
async handleAction(action, btn) {
const host = btn.getAttribute('data-host');
const index = parseInt(btn.getAttribute('data-index'));
const isProxy = btn.getAttribute('data-is-proxy') === 'true';
switch (action) {
case 'open-folder':
await api.openSiteFolder(host);
break;
case 'edit-vaccess':
if (window.editVAccess) {
window.editVAccess(host, isProxy);
}
break;
case 'edit-site':
if (window.editSite) {
window.editSite(index);
}
break;
}
}
/**
* Открыть ссылку
*/
openLink(url) {
if (window.runtime?.BrowserOpenURL) {
window.runtime.BrowserOpenURL(url);
} else {
window.open(url, '_blank');
}
}
}

View File

@@ -0,0 +1,396 @@
/* ============================================
vAccess Component
Управление правилами доступа
============================================ */
import { api } from '../api/wails.js';
import { $, hide, show } from '../utils/dom.js';
import { notification } from '../ui/notification.js';
import { modal } from '../ui/modal.js';
import { isWailsAvailable } from '../utils/helpers.js';
/**
* Класс для управления vAccess правилами
*/
export class VAccessManager {
constructor() {
this.vAccessHost = '';
this.vAccessIsProxy = false;
this.vAccessRules = [];
this.vAccessReturnSection = 'sectionSites';
this.draggedIndex = null;
this.editingField = null;
}
/**
* Открыть редактор vAccess
*/
async open(host, isProxy) {
this.vAccessHost = host;
this.vAccessIsProxy = isProxy;
// Запоминаем откуда пришли
if ($('sectionSites').style.display !== 'none') {
this.vAccessReturnSection = 'sectionSites';
} else if ($('sectionProxy').style.display !== 'none') {
this.vAccessReturnSection = 'sectionProxy';
}
// Загружаем правила
if (isWailsAvailable()) {
const config = await api.getVAccessRules(host, isProxy);
this.vAccessRules = config.rules || [];
} else {
// Тестовые данные для браузерного режима
this.vAccessRules = [
{
type: 'Disable',
type_file: ['*.php'],
path_access: ['/uploads/*'],
ip_list: [],
exceptions_dir: [],
url_error: '404'
}
];
}
// Обновляем UI
const subtitle = isProxy
? 'Управление правилами доступа для прокси-сервиса'
: 'Управление правилами доступа для сайта';
$('breadcrumbHost').textContent = host;
$('vAccessSubtitle').textContent = subtitle;
// Переключаем на страницу редактора
this.hideAllSections();
show($('sectionVAccessEditor'));
// Рендерим правила и показываем правильную вкладку
this.renderRulesList();
this.switchTab('rules');
// Привязываем кнопку сохранения
const saveBtn = $('saveVAccessBtn');
if (saveBtn) {
saveBtn.onclick = async () => await this.save();
}
}
/**
* Скрыть все секции
*/
hideAllSections() {
hide($('sectionServices'));
hide($('sectionSites'));
hide($('sectionProxy'));
hide($('sectionSettings'));
hide($('sectionVAccessEditor'));
}
/**
* Вернуться на главную
*/
backToMain() {
this.hideAllSections();
show($('sectionServices'));
show($('sectionSites'));
show($('sectionProxy'));
}
/**
* Переключить вкладку
*/
switchTab(tab) {
const tabs = document.querySelectorAll('.vaccess-tab[data-tab]');
tabs.forEach(t => {
if (t.dataset.tab === tab) {
t.classList.add('active');
} else {
t.classList.remove('active');
}
});
if (tab === 'rules') {
show($('vAccessRulesTab'));
hide($('vAccessHelpTab'));
} else {
hide($('vAccessRulesTab'));
show($('vAccessHelpTab'));
}
}
/**
* Сохранить изменения
*/
async save() {
if (isWailsAvailable()) {
const config = { rules: this.vAccessRules };
const configJSON = JSON.stringify(config);
const result = await api.saveVAccessRules(this.vAccessHost, this.vAccessIsProxy, configJSON);
if (result.startsWith('Error')) {
notification.error(result, 2000);
} else {
notification.success('✅ Правила vAccess успешно сохранены', 1000);
}
} else {
// Браузерный режим - просто показываем уведомление
notification.success('Данные сохранены (тестовый режим)');
}
}
/**
* Отрисовать список правил
*/
renderRulesList() {
const tbody = $('vAccessTableBody');
const emptyState = $('vAccessEmpty');
const table = document.querySelector('.vaccess-table');
if (!tbody) return;
// Показываем/скрываем пустое состояние
if (this.vAccessRules.length === 0) {
if (table) hide(table);
if (emptyState) show(emptyState);
return;
} else {
if (table) show(table);
if (emptyState) hide(emptyState);
}
tbody.innerHTML = this.vAccessRules.map((rule, index) => `
<tr draggable="true" data-index="${index}">
<td class="drag-handle"><i class="fas fa-grip-vertical"></i></td>
<td data-field="type" data-index="${index}">
<span class="badge ${rule.type === 'Allow' ? 'badge-yes' : 'badge-no'}">${rule.type}</span>
</td>
<td data-field="type_file" data-index="${index}">
${(rule.type_file || []).length > 0 ? (rule.type_file || []).map(f => `<code class="mini-tag">${f}</code>`).join(' ') : '<span class="empty-field">-</span>'}
</td>
<td data-field="path_access" data-index="${index}">
${(rule.path_access || []).length > 0 ? (rule.path_access || []).map(p => `<code class="mini-tag">${p}</code>`).join(' ') : '<span class="empty-field">-</span>'}
</td>
<td data-field="ip_list" data-index="${index}">
${(rule.ip_list || []).length > 0 ? (rule.ip_list || []).map(ip => `<code class="mini-tag">${ip}</code>`).join(' ') : '<span class="empty-field">-</span>'}
</td>
<td data-field="exceptions_dir" data-index="${index}">
${(rule.exceptions_dir || []).length > 0 ? (rule.exceptions_dir || []).map(e => `<code class="mini-tag">${e}</code>`).join(' ') : '<span class="empty-field">-</span>'}
</td>
<td data-field="url_error" data-index="${index}">
<code class="mini-tag">${rule.url_error || '404'}</code>
</td>
<td>
<button class="icon-btn-small" data-action="remove-rule" data-index="${index}" title="Удалить"><i class="fas fa-trash"></i></button>
</td>
</tr>
`).join('');
// Добавляем обработчики
this.attachRulesEventListeners();
}
/**
* Добавить обработчики событий для правил
*/
attachRulesEventListeners() {
// Drag & Drop
const rows = document.querySelectorAll('#vAccessTableBody tr[draggable]');
rows.forEach(row => {
row.addEventListener('dragstart', (e) => this.onDragStart(e));
row.addEventListener('dragover', (e) => this.onDragOver(e));
row.addEventListener('drop', (e) => this.onDrop(e));
});
// Клик по ячейкам для редактирования
const cells = document.querySelectorAll('#vAccessTableBody td[data-field]');
cells.forEach(cell => {
cell.addEventListener('click', () => {
const field = cell.getAttribute('data-field');
const index = parseInt(cell.getAttribute('data-index'));
this.editRuleField(index, field);
});
});
// Кнопки удаления
const removeButtons = document.querySelectorAll('[data-action="remove-rule"]');
removeButtons.forEach(btn => {
btn.addEventListener('click', () => {
const index = parseInt(btn.getAttribute('data-index'));
this.removeRule(index);
});
});
}
/**
* Добавить новое правило
*/
addRule() {
this.vAccessRules.push({
type: 'Disable',
type_file: [],
path_access: [],
ip_list: [],
exceptions_dir: [],
url_error: '404'
});
this.switchTab('rules');
this.renderRulesList();
}
/**
* Удалить правило
*/
removeRule(index) {
this.vAccessRules.splice(index, 1);
this.renderRulesList();
}
/**
* Редактировать поле правила
*/
editRuleField(index, field) {
const rule = this.vAccessRules[index];
if (field === 'type') {
// Переключаем тип
rule.type = rule.type === 'Allow' ? 'Disable' : 'Allow';
this.renderRulesList();
} else if (field === 'url_error') {
// Простой prompt для ошибки
const value = prompt('Страница ошибки:', rule.url_error || '404');
if (value !== null) {
rule.url_error = value;
this.renderRulesList();
}
} else {
// Для массивов - показываем форму редактирования
this.showFieldEditor(index, field);
}
}
/**
* Показать редактор поля
*/
showFieldEditor(index, field) {
const rule = this.vAccessRules[index];
const fieldNames = {
'type_file': 'Расширения файлов',
'path_access': 'Пути доступа',
'ip_list': 'IP адреса',
'exceptions_dir': 'Исключения'
};
const placeholders = {
'type_file': '*.php',
'path_access': '/admin/*',
'ip_list': '127.0.0.1',
'exceptions_dir': '/public/*'
};
const content = `
<div class="field-editor">
<div class="tag-input-wrapper" style="margin-bottom: 16px;">
<input type="text" class="form-input" id="fieldInput" placeholder="${placeholders[field]}">
<button class="action-btn" id="addFieldValueBtn"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="tags-container" id="fieldTags">
${(rule[field] || []).map(value => `
<span class="tag">
${value}
<button class="tag-remove" data-value="${value}"><i class="fas fa-times"></i></button>
</span>
`).join('')}
</div>
<button class="action-btn" id="closeFieldEditorBtn" style="margin-top: 20px;">
<i class="fas fa-check"></i> Готово
</button>
</div>
`;
this.editingField = { index, field };
modal.openFieldEditor(fieldNames[field], content);
// Добавляем обработчики
setTimeout(() => {
$('addFieldValueBtn')?.addEventListener('click', () => this.addFieldValue());
$('closeFieldEditorBtn')?.addEventListener('click', () => this.closeFieldEditor());
const removeButtons = document.querySelectorAll('#fieldTags .tag-remove');
removeButtons.forEach(btn => {
btn.addEventListener('click', () => {
const value = btn.getAttribute('data-value');
this.removeFieldValue(value);
});
});
}, 100);
}
/**
* Добавить значение в поле
*/
addFieldValue() {
const input = $('fieldInput');
const value = input?.value.trim();
if (value && this.editingField) {
const { index, field } = this.editingField;
if (!this.vAccessRules[index][field]) {
this.vAccessRules[index][field] = [];
}
this.vAccessRules[index][field].push(value);
input.value = '';
this.showFieldEditor(index, field);
}
}
/**
* Удалить значение из поля
*/
removeFieldValue(value) {
if (this.editingField) {
const { index, field } = this.editingField;
const arr = this.vAccessRules[index][field];
const idx = arr.indexOf(value);
if (idx > -1) {
arr.splice(idx, 1);
this.showFieldEditor(index, field);
}
}
}
/**
* Закрыть редактор поля
*/
closeFieldEditor() {
modal.closeFieldEditor();
this.renderRulesList();
}
// Drag & Drop handlers
onDragStart(event) {
this.draggedIndex = parseInt(event.target.getAttribute('data-index'));
event.target.style.opacity = '0.5';
}
onDragOver(event) {
event.preventDefault();
}
onDrop(event) {
event.preventDefault();
const dropIndex = parseInt(event.target.closest('tr').getAttribute('data-index'));
if (this.draggedIndex === null || this.draggedIndex === dropIndex) return;
const draggedRule = this.vAccessRules[this.draggedIndex];
this.vAccessRules.splice(this.draggedIndex, 1);
this.vAccessRules.splice(dropIndex, 0, draggedRule);
this.draggedIndex = null;
this.renderRulesList();
}
}