add global config support

This commit is contained in:
wjqserver 2025-06-30 15:31:14 +08:00
parent cd1e1a42f3
commit 34d553a890
23 changed files with 1682 additions and 343 deletions

View file

@ -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
View 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
View 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(); // 刷新页面以应用所有翻译是最简单可靠的方式
}
}

View file

@ -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');
});
}