From 30da719d572c603b62f7e29497733fd8c038a6f0 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:04:14 +0800 Subject: [PATCH] add multupstream && new select --- frontend/css/style.css | 80 +++++++++++------------- frontend/index.html | 74 +++++++++++++--------- frontend/js/app.js | 90 +++++++++++++++++---------- frontend/js/ui.js | 137 +++++++++++++++++++++++------------------ 4 files changed, 218 insertions(+), 163 deletions(-) diff --git a/frontend/css/style.css b/frontend/css/style.css index 52276c7..b2c67f5 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -37,8 +37,6 @@ body { transition: background-color var(--transition-speed), color var(--transition-speed); } .hidden { display: none !important; } - -/* --- 登录页 --- */ .login-page-body { display: flex; align-items: center; @@ -56,7 +54,7 @@ body { text-align: center; } .login-header { margin-bottom: 32px; } -.login-header .fa-rocket { +.login-header .fa-rocket, .login-header .fa-magic-wand-sparkles { font-size: 2.5rem; color: var(--primary-color); margin-bottom: 16px; @@ -71,10 +69,6 @@ body { text-align: left; margin-bottom: 20px; } -#init-form .form-group { - text-align: left; - margin-bottom: 20px; -} .btn-login { margin-top: 16px; width: 100%; @@ -82,16 +76,6 @@ body { padding-left: 24px; padding-right: 24px; } -.error-text { - color: var(--danger-color); - font-size: 0.875rem; - text-align: left; - margin-top: -12px; - margin-bottom: 16px; - min-height: 1.2em; -} - -/* --- Toast 通知 --- */ .toast-container { position: fixed; top: 20px; @@ -125,8 +109,6 @@ body { .toast.success .toast-icon { color: var(--success-color); } .toast.error .toast-icon { color: var(--danger-color); } .toast.info .toast-icon { color: var(--primary-color); } - -/* --- 交互式对话框/Modal --- */ #dialog-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; display: flex; align-items: center; justify-content: center; @@ -150,8 +132,6 @@ body { .dialog-message { margin: 16px 0 24px; font-size: 1rem; color: var(--text-color-secondary); } .dialog-actions { display: flex; justify-content: center; gap: 12px; } .dialog-actions .btn { width: auto; } - -/* --- 主应用布局 --- */ .app-container { display: flex; height: 100vh; } .main-content { flex-grow: 1; padding: 24px 32px; overflow-y: auto; } #view-container { position: relative; } @@ -364,34 +344,46 @@ fieldset { border: none; padding: 0; margin: 0 0 24px 0; } width: 100%; padding: 10px; background-color: var(--bg-color); border: 1px solid var(--border-color); border-radius: var(--border-radius-small); color: var(--text-color); } -.custom-select { position: relative; } -.select-selected { - display: flex; justify-content: space-between; align-items: center; - padding: 12px; background-color: var(--surface-color); border: 1px solid var(--border-color); - border-radius: var(--border-radius-small); cursor: pointer; user-select: none; - transition: border-color 0.2s, box-shadow 0.2s, background-color var(--transition-speed); +.segmented-control { + position: relative; + display: flex; + width: 100%; + background-color: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-small); + padding: 4px; + transition: background-color var(--transition-speed), border-color var(--transition-speed); } -.select-selected.select-arrow-active { - border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2); +.segmented-control button { + flex: 1; + padding: 8px 12px; + border: none; + background-color: transparent; + color: var(--text-color-secondary); + font-weight: 600; + cursor: pointer; + transition: color 0.2s ease; + z-index: 2; } -.select-selected::after { - content: '\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; - transition: transform var(--transition-speed) ease; +.segmented-control button:hover { + color: var(--text-color); } -.select-selected.select-arrow-active::after { transform: rotate(180deg); } -.select-items { - position: absolute; background-color: var(--surface-color); top: 100%; left: 0; right: 0; - border: 1px solid var(--border-color); border-radius: var(--border-radius-small); - box-shadow: 0 8px 16px rgba(0,0,0,0.1); z-index: 99; - margin-top: 8px; max-height: 200px; overflow-y: auto; - opacity: 0; transform: translateY(-10px); visibility: hidden; - transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s; +.segmented-control button.active { + color: white; } -.select-items.select-show { opacity: 1; transform: translateY(0); visibility: visible; } -.select-items div { - padding: 12px 16px; cursor: pointer; transition: background-color 0.2s; +[data-theme="dark"] .segmented-control button.active { + color: var(--text-color); +} +#segmented-control-slider { + position: absolute; + top: 4px; + bottom: 4px; + background-color: var(--primary-color); + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), width 0.25s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1; } -.select-items div:hover, .same-as-selected { background-color: var(--bg-color); } #rendered-output-panel pre { background-color: var(--bg-color); padding: 16px; border-radius: var(--border-radius-small); overflow-x: auto; white-space: pre-wrap; word-break: break-all; diff --git a/frontend/index.html b/frontend/index.html index 34e9fe6..529e34f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -69,38 +69,57 @@
-
+
+ 基础配置
- +
-
- -
-
-
- - - + +
+ 服务模式 +
+
+ + +
+ + - - - +
全局请求头 (Headers)
@@ -119,7 +137,7 @@
- 功能开关 + 附加功能
diff --git a/frontend/js/app.js b/frontend/js/app.js index e150ee2..ebe8b34 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -4,7 +4,7 @@ import { state } from './state.js'; import { api } from './api.js'; import { initTheme } from './theme.js'; import { notification } from './notifications.js'; -import { DOMElements, switchView, renderConfigList, createCustomSelect, addKeyValueInput, fillForm, showRenderedConfig, updateCaddyStatusView, updateFormVisibility } from './ui.js'; +import { DOMElements, switchView, renderConfigList, addKeyValueInput, addSingleInput, fillForm, showRenderedConfig, updateCaddyStatusView, updateSegmentedControl, updateServiceModeView, updateMultiUpstreamView } from './ui.js'; const POLLING_INTERVAL = 5000; let caddyStatusInterval; @@ -12,6 +12,8 @@ let caddyStatusInterval; function getFormStateAsString() { const formData = new FormData(DOMElements.configForm); const data = {}; + const activeModeButton = DOMElements.serviceModeControl.querySelector('button.active'); + data.active_mode = activeModeButton ? activeModeButton.dataset.mode : 'none'; for (const [key, value] of formData.entries()) { const el = DOMElements.configForm.querySelector(`[name="${key}"]`); if (el?.type === 'checkbox') data[key] = el.checked; @@ -79,7 +81,8 @@ async function handleEditConfig(originalFilename) { DOMElements.formTitle.textContent = '编辑配置'; fillForm(config, originalFilename); showRenderedConfig(rendered, originalFilename); - updateFormVisibility(config.tmpl_type, state.availableTemplates); + const mode = config.upstream_config?.enable_upstream ? 'reverse_proxy' : (config.file_server_config?.enable_file_server ? 'file_server' : 'none'); + updateServiceModeView(mode); state.initialFormState = getFormStateAsString(); } catch(error) { notification.toast(`加载配置详情失败: ${error.message}`, 'error'); } } @@ -94,8 +97,8 @@ async function handleDeleteConfig(filename) { async function handleSaveConfig(e) { e.preventDefault(); const formData = new FormData(DOMElements.configForm); - const filename = formData.get('domain'); - if (!filename) { + const domain = formData.get('domain'); + if (!domain) { notification.toast('域名不能为空。', 'error'); return; } @@ -109,20 +112,32 @@ async function handleSaveConfig(e) { }); return headers; }; - const globalHeaders = getHeadersMap('header_key', 'header_value'); - const upstreamHeaders = getHeadersMap('upstream_header_key', 'upstream_header_value'); + const activeModeButton = DOMElements.serviceModeControl.querySelector('button.active'); + const activeMode = activeModeButton ? activeModeButton.dataset.mode : 'none'; + const isMutiUpstream = DOMElements.mutiUpstreamCheckbox.checked; + const configData = { - domain: filename, - tmpl_type: formData.get('tmpl_type'), - upstream: { upstream: formData.get('upstream'), muti_upstream: false, upstreams: [], upstream_headers: upstreamHeaders }, - file_server: { file_dir_path: formData.get('file_dir_path'), enable_browser: DOMElements.configForm.querySelector('#enable_browser').checked }, - headers: globalHeaders, - log: { enable_log: DOMElements.configForm.querySelector('#enable_log').checked, log_domain: filename }, - error_page: { enable_error_page: DOMElements.configForm.querySelector('#enable_error_page').checked }, - encode: { enable_encode: DOMElements.configForm.querySelector('#enable_encode').checked } + mode: "uni", + domain_config: { domain: domain, muti_domains: false, domains: [] }, + upstream_config: { + enable_upstream: activeMode === 'reverse_proxy', + upstream: formData.get('upstream'), + muti_upstream: isMutiUpstream, + upstream_servers: formData.getAll('upstream_servers').filter(s => s), + upstream_headers: getHeadersMap('upstream_header_key', 'upstream_header_value'), + }, + file_server_config: { + enable_file_server: activeMode === 'file_server', + file_dir_path: formData.get('file_dir_path'), + enable_browser: DOMElements.configForm.querySelector('#enable_browser').checked + }, + headers: getHeadersMap('header_key', 'header_value'), + log_config: { enable_log: DOMElements.configForm.querySelector('#enable_log').checked, log_domain: domain }, + error_page_config: { enable_error_page: DOMElements.configForm.querySelector('#enable_error_page').checked }, + encode_config: { enable_encode: DOMElements.configForm.querySelector('#enable_encode').checked } }; try { - const result = await api.put(`/config/file/${filename}`, configData); + const result = await api.put(`/config/file/${domain}`, configData); state.isEditing = false; notification.toast(result.message || '配置已成功保存。', 'success'); setTimeout(() => { @@ -131,46 +146,45 @@ async function handleSaveConfig(e) { }, 500); } catch(error) { notification.toast(`保存失败: ${error.message}`, 'error'); } } + function init() { initTheme(DOMElements.themeToggleInput); notification.init(DOMElements.toastContainer, DOMElements.dialogContainer); loadAllConfigs(); - api.get('/config/templates') - .then(templates => { - state.availableTemplates = templates || []; - const options = state.availableTemplates.length > 0 ? state.availableTemplates : ['无可用模板']; - createCustomSelect('custom-select-tmpl', options, (selectedValue) => { - updateFormVisibility(selectedValue, state.availableTemplates); - }); - if (options[0] !== '无可用模板') updateFormVisibility(options[0], state.availableTemplates); - }) - .catch(err => { if(err.message) notification.toast(`加载模板失败: ${err.message}`, 'error') }); checkCaddyStatus(); caddyStatusInterval = setInterval(checkCaddyStatus, POLLING_INTERVAL); + DOMElements.menuToggleBtn.addEventListener('click', () => DOMElements.sidebar.classList.toggle('is-open')); DOMElements.mainContent.addEventListener('click', () => DOMElements.sidebar.classList.remove('is-open')); + DOMElements.addNewConfigBtn.addEventListener('click', () => { state.isEditing = false; switchView(DOMElements.configFormPanel); DOMElements.formTitle.textContent = '创建新配置'; DOMElements.configForm.reset(); - if (state.availableTemplates.length > 0) { - const selectContainer = document.getElementById('custom-select-tmpl'); - selectContainer.querySelector('.select-selected').textContent = state.availableTemplates[0]; - selectContainer.querySelector('input[type="hidden"]').value = state.availableTemplates[0]; - updateFormVisibility(state.availableTemplates[0], state.availableTemplates); - } + + const noneButton = DOMElements.serviceModeControl.querySelector('[data-mode="none"]'); + if(noneButton) updateSegmentedControl(noneButton); + updateServiceModeView('none'); + updateMultiUpstreamView(false); + state.initialFormState = getFormStateAsString(); DOMElements.headersContainer.innerHTML = ''; DOMElements.upstreamHeadersContainer.innerHTML = ''; + DOMElements.multiUpstreamContainer.innerHTML = ''; DOMElements.originalFilenameInput.value = ''; }); + DOMElements.backToListBtn.addEventListener('click', attemptExitForm); DOMElements.cancelEditBtn.addEventListener('click', attemptExitForm); + DOMElements.addHeaderBtn.addEventListener('click', () => addKeyValueInput(DOMElements.headersContainer, 'header_key', 'header_value')); DOMElements.addUpstreamHeaderBtn.addEventListener('click', () => addKeyValueInput(DOMElements.upstreamHeadersContainer, 'upstream_header_key', 'upstream_header_value')); + DOMElements.addMultiUpstreamBtn.addEventListener('click', () => addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', '例如: 127.0.0.1:8081')); + DOMElements.configForm.addEventListener('submit', handleSaveConfig); DOMElements.logoutBtn.addEventListener('click', handleLogout); + DOMElements.configListContainer.addEventListener('click', e => { const button = e.target.closest('button'); if (!button) return; @@ -178,5 +192,19 @@ function init() { if (button.classList.contains('edit-btn')) handleEditConfig(filename); if (button.classList.contains('delete-btn')) handleDeleteConfig(filename); }); + + DOMElements.serviceModeControl.addEventListener('click', (e) => { + const button = e.target.closest('button'); + if (button) { + const mode = button.dataset.mode; + updateSegmentedControl(button); + updateServiceModeView(mode); + } + }); + + DOMElements.mutiUpstreamCheckbox.addEventListener('change', (e) => { + updateMultiUpstreamView(e.target.checked); + }); } + init(); \ No newline at end of file diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 1539fae..f971bde 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -1,6 +1,5 @@ // js/ui.js - 管理所有与UI渲染和DOM操作相关的函数 -// 集中管理所有需要操作的DOM元素 export const DOMElements = { sidebar: document.getElementById('sidebar'), menuToggleBtn: document.getElementById('menu-toggle-btn'), @@ -25,10 +24,16 @@ export const DOMElements = { caddyStatusIndicator: document.getElementById('caddy-status-indicator'), caddyActionButtonContainer: document.getElementById('caddy-action-button-container'), logoutBtn: document.getElementById('logout-btn'), + serviceModeControl: document.getElementById('service-mode-control'), upstreamFieldset: document.getElementById('upstream-fieldset'), fileserverFieldset: document.getElementById('fileserver-fieldset'), upstreamHeadersContainer: document.getElementById('upstream-headers-container'), addUpstreamHeaderBtn: document.getElementById('add-upstream-header-btn'), + mutiUpstreamCheckbox: document.getElementById('muti_upstream'), + singleUpstreamGroup: document.getElementById('single-upstream-group'), + multiUpstreamGroup: document.getElementById('multi-upstream-group'), + multiUpstreamContainer: document.getElementById('multi-upstream-container'), + addMultiUpstreamBtn: document.getElementById('add-multi-upstream-btn'), }; export function switchView(viewToShow) { @@ -53,48 +58,6 @@ export function renderConfigList(filenames) { }); } -export function createCustomSelect(containerId, options, onSelect) { - const container = document.getElementById(containerId); - container.innerHTML = `
`; - const selectedDiv = container.querySelector('.select-selected'); - const itemsDiv = container.querySelector('.select-items'); - const hiddenInput = container.querySelector('input[type="hidden"]'); - itemsDiv.innerHTML = ''; - options.forEach((option, index) => { - const item = document.createElement('div'); - item.textContent = option; - item.dataset.value = option; - if (index === 0) { - selectedDiv.textContent = option; - hiddenInput.value = option; - } - item.addEventListener('click', function(e) { - selectedDiv.textContent = this.textContent; - hiddenInput.value = this.dataset.value; - itemsDiv.classList.remove('select-show'); - selectedDiv.classList.remove('select-arrow-active'); - onSelect && onSelect(this.dataset.value); - e.stopPropagation(); - }); - itemsDiv.appendChild(item); - }); - selectedDiv.addEventListener('click', (e) => { - e.stopPropagation(); - document.querySelectorAll('.select-items.select-show').forEach(openSelect => { - if (openSelect !== itemsDiv) { - openSelect.classList.remove('select-show'); - openSelect.previousElementSibling.classList.remove('select-arrow-active'); - } - }); - itemsDiv.classList.toggle('select-show'); - selectedDiv.classList.toggle('select-arrow-active'); - }); - document.addEventListener('click', () => { - itemsDiv.classList.remove('select-show'); - selectedDiv.classList.remove('select-arrow-active'); - }); -} - export function addKeyValueInput(container, keyName, valueName, key = '', value = '') { const div = document.createElement('div'); div.className = 'header-entry'; @@ -107,22 +70,58 @@ export function addKeyValueInput(container, keyName, valueName, key = '', value container.appendChild(div); } +export function addSingleInput(container, inputName, placeholder, value = '') { + const div = document.createElement('div'); + div.className = 'header-entry'; + div.style.gridTemplateColumns = '1fr auto'; + div.innerHTML = ` + + `; + container.appendChild(div); +} + export function fillForm(config, originalFilename) { DOMElements.originalFilenameInput.value = originalFilename; - DOMElements.domainInput.value = config.domain; - document.getElementById('upstream').value = config.upstream?.upstream || ''; - document.getElementById('file_dir_path').value = config.file_server?.file_dir_path || ''; - document.getElementById('enable_browser').checked = config.file_server?.enable_browser || false; - const selectContainer = document.getElementById('custom-select-tmpl'); - selectContainer.querySelector('.select-selected').textContent = config.tmpl_type; - selectContainer.querySelector('input[type="hidden"]').value = config.tmpl_type; + DOMElements.domainInput.value = config.domain_config?.domain || ''; + + const enableUpstream = config.upstream_config?.enable_upstream || false; + const enableFileServer = config.file_server_config?.enable_file_server || false; + let mode = 'none'; + if (enableUpstream) mode = 'reverse_proxy'; + else if (enableFileServer) mode = 'file_server'; + + const activeButton = DOMElements.serviceModeControl.querySelector(`[data-mode="${mode}"]`); + if (activeButton) updateSegmentedControl(activeButton); + + const upstreamConfig = config.upstream_config || {}; + DOMElements.mutiUpstreamCheckbox.checked = upstreamConfig.muti_upstream || false; + document.getElementById('upstream').value = upstreamConfig.upstream || ''; + + DOMElements.multiUpstreamContainer.innerHTML = ''; + if (upstreamConfig.muti_upstream && upstreamConfig.upstream_servers) { + upstreamConfig.upstream_servers.forEach(server => { + addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', '例如: 127.0.0.1:8081', server); + }); + } + + DOMElements.upstreamHeadersContainer.innerHTML = ''; + if (upstreamConfig.upstream_headers) { + Object.entries(upstreamConfig.upstream_headers).forEach(([k, v]) => v.forEach(val => addKeyValueInput(DOMElements.upstreamHeadersContainer, 'upstream_header_key', 'upstream_header_value', k, val))); + } + + document.getElementById('file_dir_path').value = config.file_server_config?.file_dir_path || ''; + document.getElementById('enable_browser').checked = config.file_server_config?.enable_browser || false; + DOMElements.headersContainer.innerHTML = ''; if (config.headers) Object.entries(config.headers).forEach(([k, v]) => v.forEach(val => addKeyValueInput(DOMElements.headersContainer, 'header_key', 'header_value', k, val))); - DOMElements.upstreamHeadersContainer.innerHTML = ''; - if (config.upstream?.upstream_headers) Object.entries(config.upstream.upstream_headers).forEach(([k, v]) => v.forEach(val => addKeyValueInput(DOMElements.upstreamHeadersContainer, 'upstream_header_key', 'upstream_header_value', k, val))); - document.getElementById('enable_log').checked = config.log?.enable_log || false; - document.getElementById('enable_error_page').checked = config.error_page?.enable_error_page || false; - document.getElementById('enable_encode').checked = config.encode?.enable_encode || false; + + document.getElementById('enable_log').checked = config.log_config?.enable_log || false; + document.getElementById('enable_error_page').checked = config.error_page_config?.enable_error_page || false; + document.getElementById('enable_encode').checked = config.encode_config?.enable_encode || false; + + updateMultiUpstreamView(DOMElements.mutiUpstreamCheckbox.checked); } export function showRenderedConfig(configs, filename) { @@ -168,9 +167,27 @@ export function updateCaddyStatusView(status, handlers) { dot.classList.add(dotClass); } -export function updateFormVisibility(selectedTemplate, availableTemplates) { - const showUpstream = selectedTemplate === 'reverse_proxy' && availableTemplates.includes('reverse_proxy'); - const showFileServer = selectedTemplate === 'file_server' && availableTemplates.includes('file_server'); - DOMElements.upstreamFieldset.classList.toggle('hidden', !showUpstream); - DOMElements.fileserverFieldset.classList.toggle('hidden', !showFileServer); +export function updateServiceModeView(mode) { + DOMElements.upstreamFieldset.classList.toggle('hidden', mode !== 'reverse_proxy'); + DOMElements.fileserverFieldset.classList.toggle('hidden', mode !== 'file_server'); +} + +export function updateMultiUpstreamView(isMulti) { + DOMElements.singleUpstreamGroup.classList.toggle('hidden', isMulti); + DOMElements.multiUpstreamGroup.classList.toggle('hidden', !isMulti); +} + +export function updateSegmentedControl(activeButton) { + const slider = document.getElementById('segmented-control-slider'); + const control = DOMElements.serviceModeControl; + if (!activeButton || !slider || !control) return; + + control.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); + activeButton.classList.add('active'); + + const controlRect = control.getBoundingClientRect(); + const buttonRect = activeButton.getBoundingClientRect(); + + slider.style.width = `${buttonRect.width}px`; + slider.style.transform = `translateX(${activeButton.offsetLeft - control.clientLeft - 4}px)`; } \ No newline at end of file