add global config support
This commit is contained in:
parent
cd1e1a42f3
commit
34d553a890
23 changed files with 1682 additions and 343 deletions
|
|
@ -25,22 +25,26 @@ function getFormStateAsString() {
|
|||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
async function attemptExitForm() {
|
||||
if (getFormStateAsString() !== state.initialFormState) {
|
||||
if (await notification.confirm('您有未保存的更改。确定要放弃吗?')) switchView(DOMElements.configListPanel);
|
||||
} else switchView(DOMElements.configListPanel);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (!await notification.confirm('您确定要退出登录吗?')) return;
|
||||
notification.toast('正在退出...', 'info');
|
||||
setTimeout(() => { window.location.href = `/v0/api/auth/logout`; }, 500);
|
||||
}
|
||||
|
||||
async function loadAllConfigs() {
|
||||
try {
|
||||
const filenames = await api.get('/config/filenames');
|
||||
renderConfigList(filenames);
|
||||
} catch(error) { if (error.message) notification.toast(`加载配置列表失败: ${error.message}`, 'error'); }
|
||||
} catch (error) { if (error.message) notification.toast(`加载配置列表失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function handleEditConfig(originalFilename) {
|
||||
try {
|
||||
const [config, rendered] = await Promise.all([
|
||||
|
|
@ -55,16 +59,18 @@ async function handleEditConfig(originalFilename) {
|
|||
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'); }
|
||||
} catch (error) { notification.toast(`加载配置详情失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function handleDeleteConfig(filename) {
|
||||
if (!await notification.confirm(`确定要删除配置 "${filename}" 吗?`)) return;
|
||||
try {
|
||||
await api.delete(`/config/file/${filename}`);
|
||||
notification.toast('配置已成功删除。', 'success');
|
||||
loadAllConfigs();
|
||||
} catch(error) { notification.toast(`删除失败: ${error.message}`, 'error'); }
|
||||
} catch (error) { notification.toast(`删除失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function handleSaveConfig(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(DOMElements.configForm);
|
||||
|
|
@ -115,7 +121,7 @@ async function handleSaveConfig(e) {
|
|||
switchView(DOMElements.configListPanel);
|
||||
loadAllConfigs();
|
||||
}, 500);
|
||||
} catch(error) { notification.toast(`保存失败: ${error.message}`, 'error'); }
|
||||
} catch (error) { notification.toast(`保存失败: ${error.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function openPresetModal(targetType) {
|
||||
|
|
@ -151,11 +157,11 @@ async function openPresetModal(targetType) {
|
|||
}
|
||||
|
||||
const choice = await notification.confirm('如何填充预设?', '选择填充方式', { confirmText: '追加', cancelText: '替换' });
|
||||
|
||||
|
||||
if (choice === false) { // 用户选择了“替换”
|
||||
targetContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
|
||||
Object.entries(preset.headers).forEach(([key, values]) => {
|
||||
values.forEach(value => {
|
||||
addKeyValueInput(targetContainer, keyName, valueName, key, value);
|
||||
|
|
@ -171,29 +177,30 @@ async function openPresetModal(targetType) {
|
|||
function init() {
|
||||
theme.init(DOMElements.themeToggleInput);
|
||||
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer, DOMElements.modalContainer);
|
||||
activateNav('configs');
|
||||
initCaddyStatus();
|
||||
|
||||
|
||||
loadAllConfigs();
|
||||
|
||||
|
||||
api.get('/config/headers-presets')
|
||||
.then(presets => {
|
||||
state.headerPresets = presets || [];
|
||||
})
|
||||
.catch(err => {
|
||||
.catch(err => {
|
||||
if (err.message) notification.toast(`加载Header预设失败: ${err.message}`, 'error');
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
|
||||
const noneButton = DOMElements.serviceModeControl.querySelector('[data-mode="none"]');
|
||||
if(noneButton) updateSegmentedControl(noneButton);
|
||||
if (noneButton) updateSegmentedControl(noneButton);
|
||||
updateServiceModeView('none');
|
||||
updateMultiUpstreamView(false);
|
||||
|
||||
|
|
@ -203,13 +210,13 @@ function init() {
|
|||
DOMElements.multiUpstreamContainer.innerHTML = '';
|
||||
DOMElements.originalFilenameInput.value = '';
|
||||
});
|
||||
|
||||
|
||||
DOMElements.backToListBtn.addEventListener('click', attemptExitForm);
|
||||
DOMElements.cancelEditBtn.addEventListener('click', attemptExitForm);
|
||||
|
||||
|
||||
DOMElements.configForm.addEventListener('submit', handleSaveConfig);
|
||||
DOMElements.logoutBtn.addEventListener('click', handleLogout);
|
||||
|
||||
|
||||
DOMElements.configListContainer.addEventListener('click', e => {
|
||||
const button = e.target.closest('button');
|
||||
if (!button) return;
|
||||
|
|
@ -237,7 +244,7 @@ function init() {
|
|||
e.preventDefault();
|
||||
openPresetModal('upstream');
|
||||
});
|
||||
|
||||
|
||||
DOMElements.addMultiUpstreamBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', '例如: 127.0.0.1:8081');
|
||||
|
|
|
|||
153
frontend/js/global.js
Normal file
153
frontend/js/global.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// js/global.js - 全局配置页面的逻辑
|
||||
|
||||
import { theme, activateNav } from './common.js';
|
||||
import { api } from './api.js';
|
||||
import { notification } from './notifications.js';
|
||||
import { initCaddyStatus } from './caddy.js';
|
||||
import { createCustomSelect } from './ui.js';
|
||||
|
||||
const DOMElements = {
|
||||
globalForm: document.getElementById('global-caddy-form'),
|
||||
themeToggleInput: document.getElementById('theme-toggle-input'),
|
||||
logoutBtn: document.getElementById('logout-btn'),
|
||||
toastContainer: document.getElementById('toast-container'),
|
||||
dialogContainer: document.getElementById('dialog-container'),
|
||||
enableDnsChallengeCheckbox: document.getElementById('enable_dns_challenge'),
|
||||
globalTlsConfigGroup: document.getElementById('global-tls-config-group'),
|
||||
enableEchCheckbox: document.getElementById('enable_ech'),
|
||||
echConfigGroup: document.getElementById('ech-config-group'),
|
||||
};
|
||||
const submitButton = DOMElements.globalForm.querySelector('button[type="submit"]');
|
||||
|
||||
// 从表单收集数据, 构建成后端需要的JSON结构
|
||||
function getGlobalConfigFromForm() {
|
||||
const formData = new FormData(DOMElements.globalForm);
|
||||
const enableEch = DOMElements.enableEchCheckbox.checked;
|
||||
|
||||
return {
|
||||
debug: DOMElements.globalForm.querySelector('[name="debug"]').checked,
|
||||
ports_config: {
|
||||
admin_port: formData.get('admin_port'),
|
||||
http_port: parseInt(formData.get('http_port'), 10) || 80,
|
||||
https_port: parseInt(formData.get('https_port'), 10) || 443,
|
||||
},
|
||||
metrics: DOMElements.globalForm.querySelector('[name="metrics"]').checked,
|
||||
log_config: {
|
||||
level: DOMElements.globalForm.querySelector('input[name="log_level"]').value,
|
||||
rotate_size: formData.get('log_rotate_size'),
|
||||
rotate_keep: formData.get('log_rotate_keep'),
|
||||
rotate_keep_for_time: formData.get('log_rotate_keep_for_time'),
|
||||
},
|
||||
tls_config: {
|
||||
enable_dns_challenge: DOMElements.enableDnsChallengeCheckbox.checked,
|
||||
provider: DOMElements.globalForm.querySelector('input[name="tls_provider"]').value,
|
||||
token: formData.get('tls_token'),
|
||||
echouter_sni: enableEch ? formData.get('tls_ech_sni') : "",
|
||||
email: formData.get('tls_email'),
|
||||
},
|
||||
tls_snippet_config: {},
|
||||
};
|
||||
}
|
||||
|
||||
// 用从API获取的数据填充表单
|
||||
function fillGlobalConfigForm(config) {
|
||||
if (!config) return;
|
||||
|
||||
DOMElements.globalForm.querySelector('[name="debug"]').checked = config.debug || false;
|
||||
DOMElements.globalForm.querySelector('[name="metrics"]').checked = config.metrics || false;
|
||||
|
||||
const ports = config.ports_config || {};
|
||||
DOMElements.globalForm.querySelector('[name="admin_port"]').value = ports.admin_port || ':2019';
|
||||
DOMElements.globalForm.querySelector('[name="http_port"]').value = ports.http_port || 80;
|
||||
DOMElements.globalForm.querySelector('[name="https_port"]').value = ports.https_port || 443;
|
||||
|
||||
const log = config.log_config || {};
|
||||
const logLevelSelect = document.getElementById('select-log-level');
|
||||
const logLevel = log.level || 'INFO';
|
||||
if (logLevelSelect && logLevelSelect.querySelector('.select-selected')) {
|
||||
logLevelSelect.querySelector('.select-selected').textContent = logLevel;
|
||||
const hiddenInput = logLevelSelect.querySelector('input[name="log_level"]');
|
||||
if (hiddenInput) hiddenInput.value = logLevel;
|
||||
}
|
||||
DOMElements.globalForm.querySelector('[name="log_rotate_size"]').value = log.rotate_size || '10MB';
|
||||
DOMElements.globalForm.querySelector('[name="log_rotate_keep"]').value = log.rotate_keep || '10';
|
||||
DOMElements.globalForm.querySelector('[name="log_rotate_keep_for_time"]').value = log.rotate_keep_for_time || '24h';
|
||||
|
||||
const tls = config.tls_config || {};
|
||||
DOMElements.enableDnsChallengeCheckbox.checked = tls.enable_dns_challenge || false;
|
||||
DOMElements.globalTlsConfigGroup.classList.toggle('hidden', !DOMElements.enableDnsChallengeCheckbox.checked);
|
||||
|
||||
const tlsProviderSelect = document.getElementById('select-tls-provider');
|
||||
const provider = tls.provider || '';
|
||||
if (tlsProviderSelect && provider && tlsProviderSelect.querySelector('.select-selected')) {
|
||||
tlsProviderSelect.querySelector('.select-selected').textContent = provider;
|
||||
const hiddenProviderInput = tlsProviderSelect.querySelector('input[name="tls_provider"]');
|
||||
if (hiddenProviderInput) hiddenProviderInput.value = provider;
|
||||
}
|
||||
DOMElements.globalForm.querySelector('[name="tls_token"]').value = tls.token || '';
|
||||
DOMElements.globalForm.querySelector('[name="tls_email"]').value = tls.email || '';
|
||||
|
||||
const echOuterSni = tls.echouter_sni || '';
|
||||
DOMElements.enableEchCheckbox.checked = !!echOuterSni;
|
||||
DOMElements.echConfigGroup.classList.toggle('hidden', !DOMElements.enableEchCheckbox.checked);
|
||||
DOMElements.globalForm.querySelector('[name="tls_ech_sni"]').value = echOuterSni;
|
||||
}
|
||||
|
||||
async function handleSaveGlobalConfig(e) {
|
||||
e.preventDefault();
|
||||
const configData = getGlobalConfigFromForm();
|
||||
submitButton.disabled = true;
|
||||
submitButton.querySelector('span').textContent = "保存中...";
|
||||
|
||||
try {
|
||||
// 修正: 更新API端点路径
|
||||
const result = await api.put('/global/config', configData);
|
||||
notification.toast(result.message || '全局配置已成功保存,Caddy正在重载...', 'success');
|
||||
} catch (error) {
|
||||
notification.toast(`保存失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.querySelector('span').textContent = "保存并重载";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (await notification.confirm('您确定要退出登录吗?')) {
|
||||
notification.toast('正在退出...', 'info');
|
||||
setTimeout(() => { window.location.href = '/v0/api/auth/logout'; }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
theme.init(DOMElements.themeToggleInput);
|
||||
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer);
|
||||
activateNav('global');
|
||||
initCaddyStatus();
|
||||
|
||||
// 修正: 更新API端点路径
|
||||
api.get('/global/log/levels')
|
||||
.then(levels => createCustomSelect('select-log-level', Object.keys(levels)))
|
||||
.catch(err => notification.toast(`加载日志级别失败: ${err.message}`, 'error'));
|
||||
|
||||
// 修正: 更新API端点路径
|
||||
api.get('/global/tls/providers')
|
||||
.then(providers => createCustomSelect('select-tls-provider', Object.keys(providers)))
|
||||
.catch(err => notification.toast(`加载TLS提供商失败: ${err.message}`, 'error'));
|
||||
|
||||
// 修正: 更新API端点路径
|
||||
api.get('/global/config')
|
||||
.then(config => fillGlobalConfigForm(config))
|
||||
.catch(err => notification.toast(`加载全局配置失败: ${err.message}`, 'error'));
|
||||
|
||||
DOMElements.globalForm.addEventListener('submit', handleSaveGlobalConfig);
|
||||
DOMElements.logoutBtn.addEventListener('click', handleLogout);
|
||||
|
||||
DOMElements.enableDnsChallengeCheckbox.addEventListener('change', (e) => {
|
||||
DOMElements.globalTlsConfigGroup.classList.toggle('hidden', !e.target.checked);
|
||||
});
|
||||
DOMElements.enableEchCheckbox.addEventListener('change', (e) => {
|
||||
DOMElements.echConfigGroup.classList.toggle('hidden', !e.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
109
frontend/js/locale.js
Normal file
109
frontend/js/locale.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// js/locale.js - 国际化 (i18n) 核心模块
|
||||
|
||||
let currentLocale = {};
|
||||
let currentLang = 'en'; // 默认语言
|
||||
const supportedLangs = ['en', 'zh-CN']; // 应用支持的语言列表
|
||||
|
||||
/**
|
||||
* 加载指定的语言文件 (JSON)
|
||||
* @param {string} lang - 语言代码 (e.g., 'en', 'zh-CN')
|
||||
*/
|
||||
async function loadLocale(lang) {
|
||||
try {
|
||||
const response = await fetch(`/locales/${lang}.json?v=${Date.now()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Language file for ${lang} not found (status: ${response.status}).`);
|
||||
}
|
||||
currentLocale = await response.json();
|
||||
currentLang = lang;
|
||||
document.documentElement.lang = lang;
|
||||
} catch (error) {
|
||||
console.error("i18n Error:", error);
|
||||
// 如果加载目标语言失败, 安全回退到默认的英语
|
||||
if (lang !== 'en') {
|
||||
console.warn(`Falling back to default language 'en'.`);
|
||||
await loadLocale('en');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将加载的翻译应用到所有带有 data-i18n 属性的DOM元素上
|
||||
*/
|
||||
function applyTranslationsToDOM() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.dataset.i18n;
|
||||
const translation = t(key);
|
||||
if (translation !== key) {
|
||||
// 优先替换元素的第一个文本节点, 避免覆盖内部的 <i> 等元素
|
||||
const textNode = Array.from(el.childNodes).find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
|
||||
if (textNode) {
|
||||
// 在图标和文本之间保留一个空格
|
||||
textNode.textContent = el.querySelector('i') ? ` ${translation}` : translation;
|
||||
} else {
|
||||
el.textContent = translation;
|
||||
}
|
||||
}
|
||||
});
|
||||
// 特殊处理 title 属性
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.dataset.i18nTitle;
|
||||
const translation = t(key);
|
||||
if(translation !== key) el.title = translation;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取翻译文本, 支持点分隔的路径和占位符替换
|
||||
* @param {string} key - 翻译键 (e.g., 'pages.login.welcome')
|
||||
* @param {object} [replacements={}] - 用于替换占位符的键值对
|
||||
* @returns {string} - 翻译后的字符串
|
||||
*/
|
||||
export function t(key, replacements = {}) {
|
||||
// 通过路径 'a.b.c' 在嵌套对象中查找值: currentLocale['a']['b']['c']
|
||||
const translation = key.split('.').reduce((obj, k) => obj && obj[k], currentLocale);
|
||||
|
||||
let result = translation || key; // 如果找不到, 返回原始key作为回退
|
||||
|
||||
// 处理占位符替换, e.g., {filename: 'example.com'}
|
||||
if (typeof result === 'string') {
|
||||
for (const placeholder in replacements) {
|
||||
result = result.replace(`{${placeholder}}`, replacements[placeholder]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 i18n 系统: 检测语言, 加载语言包, 并应用翻译
|
||||
*/
|
||||
export async function initI18n() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const langFromUrl = urlParams.get('lang');
|
||||
const langFromStorage = localStorage.getItem('appLanguage');
|
||||
const browserLang = navigator.language.startsWith('zh') ? 'zh-CN' : 'en';
|
||||
let langToLoad = 'en';
|
||||
|
||||
if (langFromUrl && supportedLangs.includes(langFromUrl)) {
|
||||
langToLoad = langFromUrl;
|
||||
} else if (langFromStorage && supportedLangs.includes(langFromStorage)) {
|
||||
langToLoad = langFromStorage;
|
||||
} else if (supportedLangs.includes(browserLang)) {
|
||||
langToLoad = browserLang;
|
||||
}
|
||||
|
||||
await loadLocale(langToLoad);
|
||||
applyTranslationsToDOM();
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换应用语言
|
||||
* @param {string} lang - 目标语言代码
|
||||
*/
|
||||
export async function setLanguage(lang) {
|
||||
if (supportedLangs.includes(lang) && lang !== currentLang) {
|
||||
localStorage.setItem('appLanguage', lang);
|
||||
window.location.reload(); // 刷新页面以应用所有翻译是最简单可靠的方式
|
||||
}
|
||||
}
|
||||
|
|
@ -227,4 +227,58 @@ export function createPresetSelectionModal(presets) {
|
|||
|
||||
modalContainer.addEventListener('click', eventHandler);
|
||||
});
|
||||
}
|
||||
|
||||
export function createCustomSelect(containerId, options, onSelect) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
// 从容器的ID动态生成隐藏input的name属性
|
||||
// 例如, id 'select-log-level' -> name 'log_level'
|
||||
const inputName = container.id.replace('select-', '').replace(/-/g, '_');
|
||||
|
||||
container.innerHTML = `<div class="select-selected"></div><div class="select-items"></div><input type="hidden" name="${inputName}">`;
|
||||
const selectedDiv = container.querySelector('.select-selected');
|
||||
const itemsDiv = container.querySelector('.select-items');
|
||||
const hiddenInput = container.querySelector('input[type="hidden"]');
|
||||
itemsDiv.innerHTML = '';
|
||||
|
||||
if (!options || options.length === 0) {
|
||||
selectedDiv.textContent = '无可用选项';
|
||||
return;
|
||||
}
|
||||
|
||||
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');
|
||||
if(selectedDiv) selectedDiv.classList.remove('select-arrow-active');
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue