add i18n step1

This commit is contained in:
wjqserver 2025-07-01 07:04:39 +08:00
parent 34d553a890
commit 79e3db6078
23 changed files with 2309 additions and 450 deletions

View file

@ -24,6 +24,7 @@ var (
prefixMatchPaths = []string{ // 保持前缀匹配,因为数量少
"/js/",
"/css/",
"/locales",
}
loginMatchPaths = map[string]struct{}{
"/login": {},

1094
frontend/css/.style.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -46,6 +46,7 @@ body {
color: var(--text-color);
line-height: 1.6;
overflow: hidden;
/* 防止主页面滚动条出现, 内部组件自行管理滚动 */
transition: background-color var(--transition-speed), color var(--transition-speed);
}
@ -53,8 +54,7 @@ body {
display: none !important;
}
/* --- 新增: 自定义滚动条样式 --- */
/* 适用于 Webkit 内核浏览器 (Chrome, Safari, Edge) */
/* --- 自定义滚动条样式 (适用于 Webkit 内核浏览器) --- */
::-webkit-scrollbar {
width: 8px;
height: 8px;
@ -69,18 +69,21 @@ body {
border-radius: 10px;
border: 2px solid transparent;
background-clip: content-box;
/* 使边框内缩 */
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover-color);
}
/* 适用于 Firefox */
/* --- 自定义滚动条样式 (适用于 Firefox) --- */
* {
scrollbar-width: thin;
/* 使滚动条更窄 */
scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color);
}
/* --- 登录页样式 --- */
.login-page-body {
display: flex;
align-items: center;
@ -130,10 +133,12 @@ body {
margin-top: 16px;
width: 100%;
justify-content: space-between;
/* 内部元素左右对齐 */
padding-left: 24px;
padding-right: 24px;
}
/* --- Toast 提示样式 --- */
.toast-container {
position: fixed;
top: 20px;
@ -156,12 +161,14 @@ body {
width: 320px;
opacity: 0;
transform: translateX(100%);
/* 初始位置在屏幕外 */
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.show {
opacity: 1;
transform: translateX(0);
/* 显示时滑入 */
}
.toast-icon {
@ -195,6 +202,7 @@ body {
color: var(--primary-color);
}
/* --- Dialog 确认框样式 --- */
#dialog-container {
position: fixed;
top: 0;
@ -208,6 +216,7 @@ body {
background-color: rgba(0, 0, 0, 0.3);
opacity: 0;
visibility: hidden;
/* 默认隐藏 */
transition: opacity 0.2s ease, visibility 0.2s;
}
@ -225,11 +234,13 @@ body {
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
text-align: center;
transform: scale(0.95);
/* 初始略微缩小 */
transition: transform 0.2s ease;
}
#dialog-container.active .dialog-box {
transform: scale(1);
/* 显示时放大 */
}
.dialog-message {
@ -246,8 +257,10 @@ body {
.dialog-actions .btn {
width: auto;
/* 按钮宽度自适应 */
}
/* --- Modal 模态框样式 --- */
#modal-container {
position: fixed;
top: 0;
@ -261,6 +274,7 @@ body {
background-color: rgba(0, 0, 0, 0.4);
opacity: 0;
visibility: hidden;
/* 默认隐藏 */
transition: opacity 0.2s ease, visibility 0.2s;
}
@ -277,11 +291,13 @@ body {
text-align: left;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
transform: scale(0.95) translateY(-10px);
/* 初始略微缩小并上移 */
transition: transform 0.25s ease;
}
#modal-container.active .modal-box {
transform: scale(1) translateY(0);
/* 显示时放大并恢复位置 */
}
.modal-header {
@ -300,7 +316,9 @@ body {
.modal-content {
padding: 24px;
max-height: 60vh;
/* 限制内容高度, 避免模态框过高 */
overflow-y: auto;
/* 内容超出时显示滚动条 */
}
ul.preset-list {
@ -326,6 +344,7 @@ ul.preset-list li p {
margin-top: 4px;
}
/* --- 主应用布局 --- */
.app-container {
display: flex;
height: 100vh;
@ -333,16 +352,20 @@ ul.preset-list li p {
.main-content {
flex-grow: 1;
/* 占据剩余空间 */
padding: 24px 32px;
overflow-y: auto;
/* 允许内容垂直滚动 */
}
/* --- 视图切换动画 --- */
#view-container {
position: relative;
}
.view {
animation: fadeIn 0.5s ease;
/* 视图切换时的淡入动画 */
}
@keyframes fadeIn {
@ -357,6 +380,7 @@ ul.preset-list li p {
}
}
/* --- 侧边栏 --- */
.sidebar {
width: var(--sidebar-width);
background-color: var(--surface-color);
@ -365,6 +389,7 @@ ul.preset-list li p {
flex-direction: column;
border-right: 1px solid var(--border-color);
flex-shrink: 0;
/* 防止侧边栏被挤压 */
transition: transform var(--transition-speed) ease, background-color var(--transition-speed), border-color var(--transition-speed);
}
@ -385,6 +410,7 @@ ul.preset-list li p {
.sidebar-nav {
flex-grow: 1;
/* 占据剩余空间 */
}
.sidebar-nav ul li a {
@ -414,6 +440,7 @@ ul.preset-list li p {
.sidebar-bottom {
margin-top: auto;
/* 推到底部 */
padding-top: 16px;
border-top: 1px solid var(--border-color);
transition: border-color var(--transition-speed);
@ -536,6 +563,28 @@ input:checked+.slider:before {
transition: border-color var(--transition-speed);
}
/* 侧边栏遮罩层 (仅用于移动端) */
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 199;
/* 位于侧边栏下方 */
opacity: 0;
visibility: hidden;
/* 默认隐藏 */
transition: opacity 0.3s ease, visibility 0.3s;
}
.sidebar-overlay.is-visible {
opacity: 1;
visibility: visible;
}
/* --- 主内容区头部样式 --- */
.main-header {
display: flex;
align-items: center;
@ -550,9 +599,11 @@ input:checked+.slider:before {
#menu-toggle-btn {
display: none;
/* 默认隐藏, 仅在小屏幕显示 */
margin-right: 16px;
}
/* --- 卡片面板样式 --- */
.card-panel {
background-color: var(--surface-color);
border-radius: var(--border-radius-large);
@ -574,6 +625,7 @@ input:checked+.slider:before {
flex-grow: 1;
}
/* --- 按钮样式 --- */
.btn {
display: inline-flex;
align-items: center;
@ -586,10 +638,12 @@ input:checked+.slider:before {
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
/* 默认宽度为100% */
}
.btn:active {
transform: scale(0.97);
/* 点击时轻微缩小 */
}
.btn:disabled {
@ -598,6 +652,7 @@ input:checked+.slider:before {
cursor: not-allowed;
border-color: transparent;
transform: none;
/* 禁用时无动画 */
}
[data-theme="dark"] .btn:disabled {
@ -656,6 +711,7 @@ input:checked+.slider:before {
padding: 6px 12px;
font-size: 0.875rem;
width: auto;
/* 小按钮宽度自适应 */
}
.btn-icon {
@ -668,6 +724,7 @@ input:checked+.slider:before {
font-size: 1.1rem;
border-radius: 50%;
width: auto;
/* 图标按钮宽度自适应 */
}
.btn-icon:hover {
@ -683,6 +740,7 @@ input:checked+.slider:before {
font-weight: 600;
padding: 4px 8px;
width: auto;
/* 链接按钮宽度自适应 */
}
.btn-link:hover {
@ -691,12 +749,15 @@ input:checked+.slider:before {
.main-header .btn-primary {
width: auto;
/* 主头部按钮宽度自适应 */
}
.form-actions .btn {
width: auto;
/* 表单操作按钮宽度自适应 */
}
/* --- 配置列表样式 --- */
.config-list-container {
display: flex;
flex-direction: column;
@ -715,6 +776,7 @@ input:checked+.slider:before {
.config-item:hover {
transform: translateY(-2px);
/* 悬停时轻微上浮 */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
}
@ -728,9 +790,11 @@ input:checked+.slider:before {
gap: 8px;
}
/* --- 表单样式 --- */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
/* 响应式网格布局 */
gap: 24px;
margin-bottom: 24px;
}
@ -761,14 +825,16 @@ fieldset legend {
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
}
/* 新增: 重置数字输入框的默认样式 */
/* 重置数字输入框的默认样式 (移除上下箭头) */
input[type="number"] {
-moz-appearance: textfield;
/* Firefox */
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
/* Chrome, Safari */
margin: 0;
}
@ -820,6 +886,7 @@ fieldset {
.custom-checkbox input {
display: none;
/* 隐藏原生复选框 */
}
.custom-checkbox .checkmark {
@ -837,6 +904,7 @@ fieldset {
.custom-checkbox .checkmark::after {
content: "\f00c";
/* Font Awesome 对勾图标 */
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 12px;
@ -845,20 +913,23 @@ fieldset {
top: 3px;
left: 3px;
transform: scale(0);
/* 默认隐藏 */
transition: transform 0.2s;
}
.custom-checkbox input:checked+.checkmark::after {
transform: scale(1);
/* 选中时显示 */
}
.header-entry {
display: grid;
grid-template-columns: 1fr 1fr auto;
/* 键、值、删除按钮 */
gap: 12px;
margin-bottom: 12px;
align-items: center;
/* 修改: 确保所有元素垂直居中对齐 */
/* 确保所有元素垂直居中对齐 */
}
.header-entry input {
@ -870,6 +941,7 @@ fieldset {
color: var(--text-color);
}
/* --- 分段控制器样式 --- */
.segmented-control {
position: relative;
display: flex;
@ -883,6 +955,7 @@ fieldset {
.segmented-control button {
flex: 1;
/* 均分空间 */
padding: 8px 12px;
border: none;
background-color: transparent;
@ -891,6 +964,7 @@ fieldset {
cursor: pointer;
transition: color 0.2s ease;
z-index: 2;
/* 确保按钮在滑块之上 */
}
.segmented-control button:hover {
@ -903,6 +977,7 @@ fieldset {
[data-theme="dark"] .segmented-control button.active {
color: var(--text-color);
/* 深色模式下保持文本颜色 */
}
#segmented-control-slider {
@ -913,14 +988,16 @@ fieldset {
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;
}
/* --- 自定义选择框样式 --- */
.custom-select {
position: relative;
}
/* 修改: 统一 .select-selected 的外观, 使其与 input 一致 */
/* 统一 .select-selected 的外观, 使其与 input 文本框一致 */
.select-selected {
display: flex;
justify-content: space-between;
@ -938,8 +1015,8 @@ fieldset {
transition: border-color 0.2s, box-shadow 0.2s, background-color var(--transition-speed);
line-height: 1.5;
/* 与input的字体和内边距计算出的行高一致 */
height: calc(1.5em + 24px + 2px);
/* font-size * line-height + padding * 2 + border * 2 */
/* 准确计算高度: font-size * line-height + padding-top + padding-bottom + border-top + border-bottom */
height: calc(1em * 1.5 + 12px * 2 + 1px * 2);
}
.select-selected.select-arrow-active {
@ -949,6 +1026,7 @@ fieldset {
.select-selected::after {
content: '\f078';
/* Font Awesome 向下箭头图标 */
font-family: 'Font Awesome 6 Free';
font-weight: 900;
transition: transform var(--transition-speed) ease;
@ -956,12 +1034,14 @@ fieldset {
.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);
@ -971,9 +1051,12 @@ fieldset {
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;
}
@ -994,48 +1077,102 @@ fieldset {
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;
/* 单词内断行 */
border: 1px solid var(--border-color);
transition: background-color var(--transition-speed);
}
/* --- 角落语言切换器样式 --- */
.language-switcher-corner {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 100;
}
.language-switcher-corner .btn-icon {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
width: 48px;
height: 48px;
font-size: 1.25rem;
}
.language-switcher-corner .btn-icon:hover {
background-color: var(--bg-color);
border-color: var(--primary-color);
}
.language-switcher-corner ul {
position: absolute;
bottom: 60px; /* 在按钮上方 */
right: 0;
list-style: none;
padding: 8px;
margin: 0;
background-color: var(--surface-color);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
border: 1px solid var(--border-color);
width: 150px;
}
.language-switcher-corner ul li {
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
font-weight: 500;
}
.language-switcher-corner ul li:hover {
background-color: var(--bg-color);
}
.language-switcher-corner ul li.active {
background-color: var(--primary-color);
color: white;
}
/* --- 响应式设计 (媒体查询) --- */
@media (max-width: 992px) {
.sidebar {
position: fixed;
/* 在小屏幕上脱离文档流 */
z-index: 200;
/* 确保在最上层 */
height: 100%;
transform: translateX(-100%);
/* 默认隐藏在左侧 */
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
/* 添加阴影效果 */
}
.sidebar.is-open {
transform: translateX(0);
/* 打开时滑入 */
}
#menu-toggle-btn {
display: inline-flex;
/* 仅在小屏幕上显示菜单切换按钮 */
}
.main-header .btn-text {
display: none;
/* 在小屏幕上隐藏按钮内的长文本 */
}
.sidebar-nav span,
.logout-section span {
display: inline;
}
.caddy-control-panel .btn span {
display: inline;
/* 隐藏侧边栏导航和底部区域的文本, 只显示图标 */
.main-header .btn-primary .btn-text {
display: none;
}
.main-content {
padding: 16px;
/* 调整小屏幕内边距 */
}
}

View file

@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>全局配置 - CaddyDash</title>
<title data-i18n="pages.global.page_title">全局配置 - CaddyDash</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
@ -14,18 +14,18 @@
<body>
<div class="app-container">
<!-- 侧边栏 (保持不变) -->
<aside class="sidebar" id="sidebar">
<header class="sidebar-header"><i class="fa-solid fa-rocket"></i>
<h1>CaddyDash</h1>
</header>
<nav class="sidebar-nav">
<ul>
<li><a href="/" data-nav-id="configs"><i class="fa-solid fa-sitemap"></i> <span>站点配置</span></a></li>
<li><a href="/" data-nav-id="configs"><i class="fa-solid fa-sitemap"></i> <span
data-i18n="nav.configs">站点配置</span></a></li>
<li><a href="/global.html" data-nav-id="global"><i class="fa-solid fa-globe"></i>
<span>全局配置</span></a></li>
<span data-i18n="nav.global">全局配置</span></a></li>
<li><a href="/settings.html" data-nav-id="settings"><i class="fa-solid fa-gears"></i>
<span>面板设置</span></a></li>
<span data-i18n="nav.settings">面板设置</span></a></li>
</ul>
</nav>
<div class="sidebar-bottom">
@ -34,96 +34,98 @@
</div>
<div class="caddy-control-panel">
<div id="caddy-status-indicator" class="caddy-status"><span class="status-dot checking"></span><span
class="status-text">检查中...</span></div>
class="status-text" data-i18n="status.checking">检查中...</span></div>
<div id="caddy-action-button-container"></div>
</div>
<div class="logout-section"><button id="logout-btn" class="btn btn-secondary"><i
class="fa-solid fa-right-from-bracket"></i><span>退出登录</span></button></div>
class="fa-solid fa-right-from-bracket"></i><span data-i18n="nav.logout">退出登录</span></button></div>
</div>
</aside>
<main class="main-content">
<header class="main-header">
<button class="btn-icon" id="menu-toggle-btn"><i class="fa-solid fa-bars"></i></button>
<h2>全局 Caddyfile 配置</h2>
<h2 data-i18n="pages.global.title">全局 Caddyfile 配置</h2>
</header>
<div id="view-container">
<section class="card-panel">
<p class="text-secondary" style="margin-top:-1rem; margin-bottom: 2rem;">修改这些配置将会重写您的主 Caddyfile 并触发
Caddy 重载。</p>
<p class="text-secondary" style="margin-top:-1rem; margin-bottom: 2rem;"
data-i18n="pages.global.description">修改这些配置将会重写您的主 Caddyfile 并触发 Caddy 重载。</p>
<form id="global-caddy-form">
<fieldset>
<legend>通用选项</legend>
<legend data-i18n="form.legend_general_options">通用选项</legend>
<div class="form-grid">
<div class="form-group"><label for="admin_port">管理API端口 (只读)</label><input type="text"
<div class="form-group"><label for="admin_port" data-i18n="form.admin_api_port_label">管理API端口 (只读)</label><input type="text"
id="admin_port" name="admin_port" value=":2019" readonly></div>
<div class="form-group"><label for="http_port">HTTP 端口</label><input type="number"
<div class="form-group"><label for="http_port" data-i18n="form.http_port_label">HTTP 端口</label><input type="number"
id="http_port" name="http_port" min="1" max="65535"></div>
<div class="form-group"><label for="https_port">HTTPS 端口</label><input type="number"
<div class="form-group"><label for="https_port" data-i18n="form.https_port_label">HTTPS 端口</label><input type="number"
id="https_port" name="https_port" min="1" max="65535"></div>
</div>
<div class="checkbox-grid">
<label class="custom-checkbox"><input type="checkbox" id="debug" name="debug"><span
class="checkmark"></span> 启用Debug模式</label>
class="checkmark"></span> <span data-i18n="form.enable_debug_mode">启用Debug模式</span></label>
<label class="custom-checkbox"><input type="checkbox" id="metrics" name="metrics"><span
class="checkmark"></span> 启用Prometheus指标</label>
class="checkmark"></span> <span data-i18n="form.enable_prometheus_metrics">启用Prometheus指标</span></label>
</div>
</fieldset>
<fieldset>
<legend>主日志配置</legend>
<legend data-i18n="form.legend_main_log_config">主日志配置</legend>
<div class="form-grid">
<div class="form-group"><label>日志级别</label>
<div class="form-group"><label data-i18n="form.log_level_label">日志级别</label>
<div id="select-log-level" class="custom-select"></div>
</div>
<div class="form-group"><label for="log_rotate_size">滚动大小</label><input type="text"
id="log_rotate_size" name="log_rotate_size" placeholder="例如: 10MB"></div>
<div class="form-group"><label for="log_rotate_keep">保留文件数</label><input type="text"
id="log_rotate_keep" name="log_rotate_keep" placeholder="例如: 10"></div>
<div class="form-group"><label for="log_rotate_keep_for_time">保留时间</label><input
<div class="form-group"><label for="log_rotate_size" data-i18n="form.log_rotate_size_label">滚动大小</label><input type="text"
id="log_rotate_size" name="log_rotate_size" placeholder="例如: 10MB"
data-i18n-placeholder="form.log_rotate_size_placeholder"></div>
<div class="form-group"><label for="log_rotate_keep" data-i18n="form.log_rotate_keep_label">保留文件数</label><input type="text"
id="log_rotate_keep" name="log_rotate_keep" placeholder="例如: 10"
data-i18n-placeholder="form.log_rotate_keep_placeholder"></div>
<div class="form-group"><label for="log_rotate_keep_for_time" data-i18n="form.log_rotate_keep_for_time_label">保留时间</label><input
type="text" id="log_rotate_keep_for_time" name="log_rotate_keep_for_time"
placeholder="例如: 24h"></div>
placeholder="例如: 24h" data-i18n-placeholder="form.log_rotate_keep_for_time_placeholder"></div>
</div>
</fieldset>
<fieldset>
<legend>全局TLS配置 (ACME)</legend>
<legend data-i18n="form.legend_global_tls_config">全局TLS配置 (ACME)</legend>
<label class="custom-checkbox"><input type="checkbox" id="enable_dns_challenge"
name="enable_dns_challenge"><span class="checkmark"></span> 启用全局 DNS
Challenge</label>
name="enable_dns_challenge"><span class="checkmark"></span> <span data-i18n="form.enable_global_dns_challenge">启用全局 DNS
Challenge</span></label>
<div id="global-tls-config-group" class="hidden" style="margin-top:16px;">
<div class="form-grid">
<div class="form-group"><label>DNS 提供商</label>
<div class="form-group"><label data-i18n="form.dns_provider_label">DNS 提供商</label>
<div id="select-tls-provider" class="custom-select"></div>
</div>
<div class="form-group"><label for="tls_email">ACME 邮箱</label><input type="email"
id="tls_email" name="tls_email" placeholder="用于证书申请和续订通知"></div>
<div class="form-group"><label for="tls_token">API Token (或等效凭证)</label><input
<div class="form-group"><label for="tls_email" data-i18n="form.acme_email_label">ACME 邮箱</label><input type="email"
id="tls_email" name="tls_email" placeholder="用于证书申请和续订通知"
data-i18n-placeholder="form.acme_email_placeholder"></div>
<div class="form-group"><label for="tls_token" data-i18n="form.api_token_label">API Token (或等效凭证)</label><input
type="password" id="tls_token" name="tls_token" autocomplete="new-password">
</div>
</div>
</div>
</fieldset>
<!-- 新增: ECH 配置区域 -->
<fieldset>
<legend>加密客户端问候 (ECH)</legend>
<legend data-i18n="form.legend_ech_config">加密客户端问候 (ECH)</legend>
<label class="custom-checkbox"><input type="checkbox" id="enable_ech"
name="enable_ech"><span class="checkmark"></span> 启用 ECH (实验性功能)</label>
name="enable_ech"><span class="checkmark"></span> <span data-i18n="form.enable_ech_experimental">启用 ECH (实验性功能)</span></label>
<div id="ech-config-group" class="hidden" style="margin-top:16px;">
<div class="form-group">
<label for="tls_ech_sni">ECH Outer SNI</label>
<label for="tls_ech_sni" data-i18n="form.ech_outer_sni_label">ECH Outer SNI</label>
<input type="text" id="tls_ech_sni" name="tls_ech_sni"
placeholder="例如: ech.example.com">
placeholder="例如: ech.example.com" data-i18n-placeholder="form.ech_outer_sni_placeholder">
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-save"></i>
保存并重载</button>
<span data-i18n="pages.global.save_btn">保存并重载</span></button>
</div>
</form>
</section>
@ -133,6 +135,7 @@
<div id="toast-container" class="toast-container"></div>
<div id="dialog-container"></div>
<div id="modal-container"></div>
<script type="module" src="js/common.js"></script>
<script type="module" src="js/global.js"></script>

View file

@ -1,15 +1,17 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CaddyDash</title>
<title data-i18n="pages.configs.page_title">CaddyDash</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="app-container">
<aside class="sidebar" id="sidebar">
@ -19,26 +21,35 @@
</header>
<nav class="sidebar-nav">
<ul>
<li><a href="/" data-nav-id="configs" class="active"><i class="fa-solid fa-sitemap"></i> <span>站点配置</span></a></li>
<li><a href="/global.html" data-nav-id="global"><i class="fa-solid fa-globe"></i> <span>全局配置</span></a></li>
<li><a href="/settings.html" data-nav-id="settings"><i class="fa-solid fa-gears"></i> <span>面板设置</span></a></li>
<li><a href="/" data-nav-id="configs" class="active"><i class="fa-solid fa-sitemap"></i> <span
data-i18n="nav.configs">站点配置</span></a></li>
<li><a href="/global.html" data-nav-id="global"><i class="fa-solid fa-globe"></i> <span
data-i18n="nav.global">全局配置</span></a></li>
<li><a href="/settings.html" data-nav-id="settings"><i class="fa-solid fa-gears"></i> <span
data-i18n="nav.settings">面板设置</span></a></li>
</ul>
</nav>
<div class="sidebar-bottom">
<div class="theme-switcher"><i class="fa-solid fa-sun"></i><label class="switch"><input type="checkbox" id="theme-toggle-input"><span class="slider"></span></label><i class="fa-solid fa-moon"></i></div>
<div class="theme-switcher"><i class="fa-solid fa-sun"></i><label class="switch"><input type="checkbox"
id="theme-toggle-input"><span class="slider"></span></label><i class="fa-solid fa-moon"></i>
</div>
<div class="caddy-control-panel">
<div id="caddy-status-indicator" class="caddy-status"><span class="status-dot checking"></span><span class="status-text">检查中...</span></div>
<div id="caddy-status-indicator" class="caddy-status"><span class="status-dot checking"></span><span
class="status-text" data-i18n="status.checking">检查中...</span></div>
<div id="caddy-action-button-container"></div>
</div>
<div class="logout-section"><button id="logout-btn" class="btn btn-secondary"><i class="fa-solid fa-right-from-bracket"></i><span>退出登录</span></button></div>
<div class="logout-section"><button id="logout-btn" class="btn btn-secondary"><i
class="fa-solid fa-right-from-bracket"></i><span
data-i18n="nav.logout">退出登录</span></button></div>
</div>
</aside>
<main class="main-content">
<header class="main-header">
<button class="btn-icon" id="menu-toggle-btn"><i class="fa-solid fa-bars"></i></button>
<h2>配置管理</h2>
<button id="add-new-config-btn" class="btn btn-primary"><i class="fa-solid fa-plus"></i> <span class="btn-text">创建新配置</span></button>
<h2 data-i18n="pages.configs.title">站点配置管理</h2>
<button id="add-new-config-btn" class="btn btn-primary"><i class="fa-solid fa-plus"></i> <span
class="btn-text" data-i18n="pages.configs.add_new_btn">创建新配置</span></button>
</header>
<div id="view-container">
@ -47,104 +58,123 @@
</section>
<section id="config-form-panel" class="card-panel view hidden">
<div class="form-panel-header">
<button id="back-to-list-btn" class="btn-icon" title="返回列表"><i class="fa-solid fa-arrow-left"></i></button>
<h3 id="form-title">创建新配置</h3>
<button id="back-to-list-btn" class="btn-icon" title="返回列表"
data-i18n-title="common.back_to_list"><i class="fa-solid fa-arrow-left"></i></button>
<h3 id="form-title" data-i18n="pages.configs.form_title_create">创建新配置</h3>
</div>
<form id="config-form">
<input type="hidden" id="original-filename" value="">
<fieldset>
<legend>基础配置</legend>
<legend data-i18n="form.legend_basic">基础配置</legend>
<div class="form-group">
<label for="domain">主域名 (将用作文件名)</label>
<label for="domain" data-i18n="form.domain_label">主域名 (将用作文件名)</label>
<input type="text" id="domain" name="domain" required>
</div>
</fieldset>
<fieldset>
<legend>服务模式</legend>
<legend data-i18n="form.legend_service_mode">服务模式</legend>
<div id="service-mode-control" class="segmented-control">
<div id="segmented-control-slider"></div>
<button type="button" data-mode="none" class="active"></button>
<button type="button" data-mode="reverse_proxy">反向代理</button>
<button type="button" data-mode="file_server">文件服务</button>
<button type="button" data-mode="none" class="active"
data-i18n="form.mode_none">无</button>
<button type="button" data-mode="reverse_proxy"
data-i18n="form.mode_rp">反向代理</button>
<button type="button" data-mode="file_server"
data-i18n="form.mode_fs">文件服务</button>
</div>
</fieldset>
<fieldset id="upstream-fieldset" class="hidden">
<legend>反向代理配置</legend>
<legend data-i18n="form.legend_rp">反向代理配置</legend>
<div class="form-group" id="single-upstream-group">
<label for="upstream">上游服务地址</label>
<input type="text" id="upstream" name="upstream" placeholder="例如: 127.0.0.1:8080">
<label for="upstream" data-i18n="form.upstream_addr_label">上游服务地址</label>
<input type="text" id="upstream" name="upstream" placeholder="例如: 127.0.0.1:8080"
data-i18n-placeholder="form.upstream_addr_placeholder">
</div>
<div class="sub-fieldset">
<label class="custom-checkbox">
<input type="checkbox" id="muti_upstream" name="muti_upstream">
<span class="checkmark"></span> 启用多上游负载均衡
<span class="checkmark"></span> <span
data-i18n="form.enable_multi_upstream">启用多上游负载均衡</span>
</label>
<div id="multi-upstream-group" class="hidden" style="margin-top: 16px;">
<p class="sub-legend">上游服务器列表</p>
<p class="sub-legend" data-i18n="form.upstream_servers_label">上游服务器列表</p>
<div id="multi-upstream-container"></div>
<button type="button" id="add-multi-upstream-btn" class="btn btn-secondary btn-small"><i class="fa-solid fa-plus"></i> 添加上游服务器</button>
<button type="button" id="add-multi-upstream-btn"
class="btn btn-secondary btn-small"><i class="fa-solid fa-plus"></i> <span
data-i18n="form.add_upstream_server_btn">添加上游服务器</span></button>
</div>
</div>
<div class="sub-fieldset">
<div class="sub-legend-group">
<p class="sub-legend">上游请求头 (Upstream Headers)</p>
<p class="sub-legend" data-i18n="form.upstream_headers_label">上游请求头 (Upstream
Headers)</p>
<button type="button" class="btn-link" data-preset-target="upstream">
<i class="fa-solid fa-wand-magic-sparkles"></i>
<span>从预设填充</span>
<span data-i18n="form.fill_from_preset">从预设填充</span>
</button>
</div>
<div id="upstream-headers-container"></div>
<button type="button" class="btn btn-secondary btn-small" data-add-target="upstream">
<i class="fa-solid fa-plus"></i> 添加请求头
<i class="fa-solid fa-plus"></i> <span data-i18n="form.add_header_btn">添加请求头</span>
</button>
</div>
</fieldset>
<fieldset id="fileserver-fieldset" class="hidden">
<legend>文件服务配置</legend>
<legend data-i18n="form.legend_fs">文件服务配置</legend>
<div class="form-group">
<label for="file_dir_path">根目录路径</label>
<input type="text" id="file_dir_path" name="file_dir_path" placeholder="例如: /srv/www">
<label for="file_dir_path" data-i18n="form.fs_root_label">根目录路径</label>
<input type="text" id="file_dir_path" name="file_dir_path" placeholder="例如: /srv/www"
data-i18n-placeholder="form.fs_root_placeholder">
</div>
<label class="custom-checkbox">
<input type="checkbox" id="enable_browser" name="enable_browser">
<span class="checkmark"></span> 启用文件浏览器
<span class="checkmark"></span> <span
data-i18n="form.enable_fs_browser">启用文件浏览器</span>
</label>
</fieldset>
<fieldset id="headers-fieldset">
<div class="sub-legend-group">
<legend>全局请求头 (Headers)</legend>
<legend data-i18n="form.legend_global_headers">全局请求头 (Headers)</legend>
<button type="button" class="btn-link" data-preset-target="global">
<i class="fa-solid fa-wand-magic-sparkles"></i>
<span>从预设填充</span>
<span data-i18n="form.fill_from_preset">从预设填充</span>
</button>
</div>
<div id="headers-container"></div>
<button type="button" class="btn btn-secondary btn-small" data-add-target="global">
<i class="fa-solid fa-plus"></i> 添加请求头
<i class="fa-solid fa-plus"></i> <span data-i18n="form.add_header_btn">添加请求头</span>
</button>
</fieldset>
<fieldset>
<legend>附加功能</legend>
<legend data-i18n="form.legend_features">附加功能</legend>
<div class="checkbox-grid">
<label class="custom-checkbox"><input type="checkbox" id="enable_log" name="enable_log"><span class="checkmark"></span> 启用日志</label>
<label class="custom-checkbox"><input type="checkbox" id="enable_error_page" name="enable_error_page"><span class="checkmark"></span> 启用错误页</label>
<label class="custom-checkbox"><input type="checkbox" id="enable_encode" name="enable_encode"><span class="checkmark"></span> 启用压缩</label>
<label class="custom-checkbox"><input type="checkbox" id="enable_log"
name="enable_log"><span class="checkmark"></span> <span
data-i18n="form.feature_log">启用日志</span></label>
<label class="custom-checkbox"><input type="checkbox" id="enable_error_page"
name="enable_error_page"><span class="checkmark"></span> <span
data-i18n="form.feature_error_page">启用错误页</span></label>
<label class="custom-checkbox"><input type="checkbox" id="enable_encode"
name="enable_encode"><span class="checkmark"></span> <span
data-i18n="form.feature_encode">启用压缩</span></label>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-save"></i> <span>保存配置</span></button>
<button type="button" id="cancel-edit-btn" class="btn btn-secondary">取消</button>
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-save"></i> <span
data-i18n="common.save">保存配置</span></button>
<button type="button" id="cancel-edit-btn" class="btn btn-secondary"
data-i18n="common.cancel">取消</button>
</div>
</form>
</section>
<section id="rendered-output-panel" class="card-panel view hidden">
<h3>渲染后的 Caddyfile</h3>
<h3 data-i18n="pages.configs.rendered_caddyfile_title">渲染后的 Caddyfile</h3>
<pre><code id="rendered-content"></code></pre>
</section>
</div>
@ -153,8 +183,9 @@
<div id="toast-container" class="toast-container"></div>
<div id="dialog-container"></div>
<div id="modal-container"></div> <!-- 新增: 通用模态框容器 -->
<div id="modal-container"></div>
<script type="module" src="js/app.js"></script>
</body>
</html>

View file

@ -1,56 +1,52 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>首次设置 - CaddyDash</title>
<title data-i18n="pages.init.page_title">首次设置 - CaddyDash</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="css/style.css">
<style>
#init-form .form-group {
text-align: left;
margin-bottom: 20px;
}
</style>
</head>
<body class="login-page-body">
<div class="login-container">
<header class="login-header">
<i class="fa-solid fa-magic-wand-sparkles"></i>
<h1>欢迎使用 CaddyDash</h1>
<p>请创建您的管理员账户以完成首次设置</p>
<h1 data-i18n="pages.init.title">欢迎使用 CaddyDash</h1>
<p data-i18n="pages.init.prompt">请创建您的管理员账户以完成首次设置</p>
</header>
<form id="init-form">
<div class="form-group">
<label for="username">管理员用户名</label>
<input type="text" id="username" name="username" autocomplete="username">
<label for="username" data-i18n="pages.init.admin_user_label">管理员用户名</label>
<input type="text" id="username" name="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">密码 (至少8位)</label>
<input type="password" id="password" name="password" autocomplete="new-password">
<label for="password" data-i18n="pages.init.password_label">密码 (至少8位)</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password">
</div>
<div class="form-group">
<label for="confirm_password">确认密码</label>
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password">
<label for="confirm_password" data-i18n="pages.init.confirm_password_label">确认密码</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="8" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary btn-login">
<span>完成设置</span>
<span data-i18n="pages.init.setup_btn">完成设置</span>
<i class="fa-solid fa-check"></i>
</button>
</form>
</div>
<div id="toast-container" class="toast-container"></div>
<div class="language-switcher-corner">
<button id="lang-switcher-btn" class="btn-icon" data-i18n-title="common.switch_language" title="切换语言">
<i class="fa-solid fa-language"></i>
</button>
<ul id="lang-options-list" class="hidden"></ul>
</div>
<div id="toast-container" class="toast-container"></div>
<script type="module" src="js/init.js"></script>
</body>
</html>

View file

@ -2,13 +2,12 @@
import { state } from './state.js';
import { api } from './api.js';
import { theme, activateNav } from './common.js';
import { initializePage } from './common.js';
import { t } from './locale.js';
import { notification } from './notifications.js';
import { initCaddyStatus } from './caddy.js';
import { DOMElements, switchView, renderConfigList, addKeyValueInput, addSingleInput, fillForm, showRenderedConfig, updateCaddyStatusView, updateSegmentedControl, updateServiceModeView, updateMultiUpstreamView, createPresetSelectionModal } from './ui.js';
const POLLING_INTERVAL = 5000;
let caddyStatusInterval;
// --- 事件处理与逻辑流 (所有 handle* 函数保持不变) ---
function getFormStateAsString() {
const formData = new FormData(DOMElements.configForm);
@ -27,14 +26,18 @@ function getFormStateAsString() {
}
async function attemptExitForm() {
if (getFormStateAsString() !== state.initialFormState) {
if (await notification.confirm('您有未保存的更改。确定要放弃吗?')) switchView(DOMElements.configListPanel);
} else switchView(DOMElements.configListPanel);
if (await getFormStateAsString() !== state.initialFormState) {
if (await notification.confirm(t('dialogs.unsaved_changes_msg'), t('dialogs.unsaved_changes_title'))) {
switchView(DOMElements.configListPanel);
}
} else {
switchView(DOMElements.configListPanel);
}
}
async function handleLogout() {
if (!await notification.confirm('您确定要退出登录吗?')) return;
notification.toast('正在退出...', 'info');
if (!await notification.confirm(t('dialogs.logout_msg'))) return;
notification.toast(t('toasts.logout_processing'), 'info');
setTimeout(() => { window.location.href = `/v0/api/auth/logout`; }, 500);
}
@ -42,7 +45,7 @@ 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(t('toasts.load_configs_error', { error: error.message }), 'error'); }
}
async function handleEditConfig(originalFilename) {
@ -53,22 +56,22 @@ async function handleEditConfig(originalFilename) {
]);
state.isEditing = true;
switchView(DOMElements.configFormPanel);
DOMElements.formTitle.textContent = '编辑配置';
DOMElements.formTitle.textContent = t('pages.configs.form_title_edit');
fillForm(config, originalFilename);
showRenderedConfig(rendered, 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'); }
state.initialFormState = await getFormStateAsString();
} catch (error) { notification.toast(t('toasts.load_config_detail_error', { error: error.message }), 'error'); }
}
async function handleDeleteConfig(filename) {
if (!await notification.confirm(`确定要删除配置 "${filename}" 吗?`)) return;
if (!await notification.confirm(t('dialogs.delete_config_msg', { filename }), t('dialogs.delete_config_title'))) return;
try {
await api.delete(`/config/file/${filename}`);
notification.toast('配置已成功删除。', 'success');
notification.toast(t('toasts.delete_success'), 'success');
loadAllConfigs();
} catch (error) { notification.toast(`删除失败: ${error.message}`, 'error'); }
} catch (error) { notification.toast(t('toasts.delete_error', { error: error.message }), 'error'); }
}
async function handleSaveConfig(e) {
@ -76,7 +79,7 @@ async function handleSaveConfig(e) {
const formData = new FormData(DOMElements.configForm);
const domain = formData.get('domain');
if (!domain) {
notification.toast('域名不能为空。', 'error');
notification.toast(t('toasts.error_domain_empty'), 'error');
return;
}
const getHeadersMap = (keyName, valueName) => {
@ -116,23 +119,23 @@ async function handleSaveConfig(e) {
try {
const result = await api.put(`/config/file/${domain}`, configData);
state.isEditing = false;
notification.toast(result.message || '配置已成功保存。', 'success');
notification.toast(result.message || t('toasts.save_success'), 'success');
setTimeout(() => {
switchView(DOMElements.configListPanel);
loadAllConfigs();
}, 500);
} catch (error) { notification.toast(`保存失败: ${error.message}`, 'error'); }
} catch (error) { notification.toast(t('toasts.save_error', { error: error.message }), 'error'); }
}
async function openPresetModal(targetType) {
const applicablePresets = state.headerPresets.filter(p => p.target === targetType || p.target === 'any');
if (applicablePresets.length === 0) {
notification.toast(`没有适用于此区域的预设。`, 'info');
notification.toast(t('toasts.no_presets_available'), 'info');
return;
}
const presetId = await createPresetSelectionModal(applicablePresets);
if (!presetId) return; // 用户取消了选择
const presetId = await createPresetSelectionModal(applicablePresets, t);
if (!presetId) return;
let targetContainer, keyName, valueName;
if (targetType === 'global') {
@ -147,18 +150,17 @@ async function openPresetModal(targetType) {
return;
}
// 在获取到presetId后再执行填充逻辑
try {
notification.toast('正在加载预设...', 'info', 1000);
notification.toast(t('toasts.loading_preset'), 'info', 1000);
const preset = await api.get(`/config/headers-presets/${presetId}`);
if (!preset.headers) {
notification.toast('此预设无数据。', 'info');
notification.toast(t('toasts.preset_no_data'), 'info');
return;
}
const choice = await notification.confirm('如何填充预设?', '选择填充方式', { confirmText: '追加', cancelText: '替换' });
const choice = await notification.confirm(t('dialogs.preset_fill_msg'), t('dialogs.preset_fill_title'), { confirmText: t('common.append'), cancelText: t('common.replace') });
if (choice === false) { // 用户选择了“替换”
if (choice === false) {
targetContainer.innerHTML = '';
}
@ -167,19 +169,16 @@ async function openPresetModal(targetType) {
addKeyValueInput(targetContainer, keyName, valueName, key, value);
});
});
notification.toast(`已成功填充预设 "${preset.name}"`, 'success');
notification.toast(t('toasts.preset_fill_success', { presetName: preset.name }), 'success');
} catch (error) {
notification.toast(`加载预设失败: ${error.message}`, 'error');
notification.toast(t('toasts.load_preset_error', { error: error.message }), 'error');
}
}
function init() {
theme.init(DOMElements.themeToggleInput);
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer, DOMElements.modalContainer);
activateNav('configs');
initCaddyStatus();
// --- 初始化与事件绑定 ---
function pageInit() {
// 这个函数包含所有特定于 app.js 的初始化逻辑
loadAllConfigs();
api.get('/config/headers-presets')
@ -187,16 +186,13 @@ function init() {
state.headerPresets = presets || [];
})
.catch(err => {
if (err.message) notification.toast(`加载Header预设失败: ${err.message}`, 'error');
if (err.message) notification.toast(t('toasts.load_presets_error', { error: 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', () => {
DOMElements.addNewConfigBtn.addEventListener('click', async () => {
state.isEditing = false;
switchView(DOMElements.configFormPanel);
DOMElements.formTitle.textContent = '创建新配置';
DOMElements.formTitle.textContent = t('pages.configs.form_title_create');
DOMElements.configForm.reset();
const noneButton = DOMElements.serviceModeControl.querySelector('[data-mode="none"]');
@ -204,7 +200,7 @@ function init() {
updateServiceModeView('none');
updateMultiUpstreamView(false);
state.initialFormState = getFormStateAsString();
state.initialFormState = await getFormStateAsString();
DOMElements.headersContainer.innerHTML = '';
DOMElements.upstreamHeadersContainer.innerHTML = '';
DOMElements.multiUpstreamContainer.innerHTML = '';
@ -247,7 +243,7 @@ function init() {
DOMElements.addMultiUpstreamBtn.addEventListener('click', (e) => {
e.preventDefault();
addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', '例如: 127.0.0.1:8081');
addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', t('form.upstream_server_placeholder'));
});
DOMElements.serviceModeControl.addEventListener('click', (e) => {
@ -265,4 +261,5 @@ function init() {
});
}
init();
// 使用通用初始化函数启动页面
initializePage({ pageId: 'configs', pageInit: pageInit });

View file

@ -6,6 +6,9 @@ import { notification } from './notifications.js';
let caddyStatusInterval;
const POLLING_INTERVAL = 5000;
// 将 t 函数保存在模块作用域内
let translate;
const DOMElements = {
caddyStatusIndicator: document.getElementById('caddy-status-indicator'),
caddyActionButtonContainer: document.getElementById('caddy-action-button-container'),
@ -24,23 +27,31 @@ function updateCaddyStatusView(status) {
const text = DOMElements.caddyStatusIndicator.querySelector('.status-text');
const buttonContainer = DOMElements.caddyActionButtonContainer;
if(!dot || !text || !buttonContainer) return; // 如果元素不存在,则不执行
if(!dot || !text || !buttonContainer) return;
dot.className = 'status-dot';
buttonContainer.innerHTML = '';
let statusText, dotClass;
switch (status) {
case 'running':
statusText = '运行中'; dotClass = 'running';
buttonContainer.appendChild(createButton('重载配置', 'btn-warning', handleReloadCaddy));
buttonContainer.appendChild(createButton('停止 Caddy', 'btn-danger', handleStopCaddy));
statusText = translate('status.running');
dotClass = 'running';
buttonContainer.appendChild(createButton(translate('caddy.reload_btn'), 'btn-warning', handleReloadCaddy));
buttonContainer.appendChild(createButton(translate('caddy.stop_btn'), 'btn-danger', handleStopCaddy));
break;
case 'stopped':
statusText = '已停止'; dotClass = 'stopped';
buttonContainer.appendChild(createButton('启动 Caddy', 'btn-success', handleStartCaddy));
statusText = translate('status.stopped');
dotClass = 'stopped';
buttonContainer.appendChild(createButton(translate('caddy.start_btn'), 'btn-success', handleStartCaddy));
break;
case 'checking':
statusText = translate('status.checking');
dotClass = 'checking';
break;
default:
statusText = translate('status.unknown');
dotClass = 'error';
break;
case 'checking': statusText = '检查中...'; dotClass = 'checking'; break;
default: statusText = '状态未知'; dotClass = 'error'; break;
}
text.textContent = statusText;
dot.classList.add(dotClass);
@ -59,35 +70,38 @@ async function checkCaddyStatus() {
async function handleStartCaddy() {
try {
const result = await api.post('/caddy/run');
notification.toast(result.message || '启动命令已发送。', 'success');
notification.toast(result.message || translate('toasts.start_cmd_sent'), 'success');
setTimeout(checkCaddyStatus, 500);
} catch (error) { notification.toast(`启动失败: ${error.message}`, 'error'); }
} catch (error) { notification.toast(translate('toasts.start_error', { error: error.message }), 'error'); }
}
async function handleStopCaddy() {
if (!await notification.confirm('您确定要停止 Caddy 实例吗?')) return;
if (!await notification.confirm(translate('dialogs.stop_caddy_msg'))) return;
try {
const result = await api.post('/caddy/stop');
notification.toast(result.message || '停止命令已发送。', 'info');
notification.toast(result.message || translate('toasts.stop_cmd_sent'), 'info');
setTimeout(checkCaddyStatus, 500);
} catch(error) { notification.toast(`操作失败: ${error.message}`, 'error'); }
} catch(error) { notification.toast(translate('toasts.action_error', { error: error.message }), 'error'); }
}
async function handleReloadCaddy() {
if (!await notification.confirm('确定要重载 Caddy 配置吗?')) return;
if (!await notification.confirm(translate('dialogs.reload_caddy_msg'))) return;
try {
const result = await api.post('/caddy/restart');
notification.toast(result.message || '重载命令已发送。', 'success');
notification.toast(result.message || translate('toasts.reload_sent'), 'success');
setTimeout(checkCaddyStatus, 500);
} catch(error) { notification.toast(`重载失败: ${error.message}`, 'error'); }
} catch(error) { notification.toast(translate('toasts.reload_error', { error: error.message }), 'error'); }
}
export function initCaddyStatus() {
// 确保通知模块已经初始化
// initCaddyStatus 现在接收 t 函数作为参数
export function initCaddyStatus(translator) {
// 保存翻译函数以供模块内其他函数使用
translate = translator;
const dialogContainer = document.getElementById('dialog-container');
const toastContainer = document.getElementById('toast-container');
if (dialogContainer && toastContainer) {
notification.init(toastContainer, dialogContainer);
notification.init(toastContainer, dialogContainer, null, translate); // 将 t 函数传递给通知模块
}
checkCaddyStatus();

View file

@ -1,5 +1,10 @@
// js/common.js - 存放共享模块
import { initI18n, t } from './locale.js';
import { notification } from './notifications.js';
import { initCaddyStatus } from './caddy.js';
import { initUI } from './ui.js';
const theme = {
init: (toggleElement) => {
const storedTheme = localStorage.getItem('theme');
@ -55,5 +60,71 @@ function activateNav(pageId) {
});
}
/**
* 初始化移动端侧边栏的开关逻辑
*/
function initSidebar() {
const sidebar = document.getElementById('sidebar');
const menuToggleBtn = document.getElementById('menu-toggle-btn');
// 动态创建并管理遮罩层
let overlay = document.querySelector('.sidebar-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'sidebar-overlay';
document.body.appendChild(overlay);
}
const openSidebar = () => {
if (!sidebar || !overlay) return;
sidebar.classList.add('is-open');
overlay.classList.add('is-visible');
};
const closeSidebar = () => {
if (!sidebar || !overlay) return;
sidebar.classList.remove('is-open');
overlay.classList.remove('is-visible');
};
if (menuToggleBtn) {
menuToggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
openSidebar();
});
}
overlay.addEventListener('click', closeSidebar);
}
/**
* 通用的页面初始化函数
* @param {object} options - 初始化选项
* @param {string} options.pageId - 当前页面的ID, 用于导航高亮
* @param {function} [options.pageInit=null] - 特定于该页面的额外初始化逻辑
*/
export async function initializePage(options) {
// 1. 初始化国际化 (最高优先级)
await initI18n();
initUI(t);
// 2. 初始化UI模块和通用功能
theme.init(document.getElementById('theme-toggle-input'));
notification.init(
document.getElementById('toast-container'),
document.getElementById('dialog-container'),
document.getElementById('modal-container')
);
activateNav(options.pageId);
initSidebar();
initCaddyStatus(t);
// 3. 如果有特定页面的初始化逻辑, 则执行它
if (options.pageInit && typeof options.pageInit === 'function') {
options.pageInit();
}
}
// 导出模块
export { theme, toast, activateNav };

View file

@ -1,25 +1,21 @@
// js/global.js - 全局配置页面的逻辑
import { theme, activateNav } from './common.js';
import { initializePage } 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"]');
// submitButton 在 pageInit 中获取, 确保DOM已加载
let submitButton;
// 从表单收集数据, 构建成后端需要的JSON结构
function getGlobalConfigFromForm() {
const formData = new FormData(DOMElements.globalForm);
const enableEch = DOMElements.enableEchCheckbox.checked;
@ -49,7 +45,6 @@ function getGlobalConfigFromForm() {
};
}
// 用从API获取的数据填充表单
function fillGlobalConfigForm(config) {
if (!config) return;
@ -100,7 +95,6 @@ async function handleSaveGlobalConfig(e) {
submitButton.querySelector('span').textContent = "保存中...";
try {
// 修正: 更新API端点路径
const result = await api.put('/global/config', configData);
notification.toast(result.message || '全局配置已成功保存Caddy正在重载...', 'success');
} catch (error) {
@ -118,23 +112,19 @@ async function handleLogout() {
}
}
function init() {
theme.init(DOMElements.themeToggleInput);
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer);
activateNav('global');
initCaddyStatus();
// 页面特有的初始化逻辑
function pageInit() {
// 在这里获取 submitButton, 确保 DOM 已加载
submitButton = DOMElements.globalForm.querySelector('button[type="submit"]');
// 修正: 更新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'));
@ -150,4 +140,5 @@ function init() {
});
}
init();
// 使用通用初始化函数启动页面
initializePage({ pageId: 'global', pageInit: pageInit });

View file

@ -1,116 +1,156 @@
// js/init.js
// js/init.js - 初始化页面的独立逻辑
document.addEventListener('DOMContentLoaded', () => {
const DOMElements = {
initForm: document.getElementById('init-form'),
initButton: null,
toastContainer: document.getElementById('toast-container'),
usernameInput: document.getElementById('username'),
passwordInput: document.getElementById('password'),
confirmPasswordInput: document.getElementById('confirm_password'),
langSwitcherBtn: document.getElementById('lang-switcher-btn'),
langOptionsList: document.getElementById('lang-options-list'), // 从第一个片段引入
};
const initButton = DOMElements.initForm.querySelector('button[type="submit"]');
const INIT_API_URL = '/v0/api/auth/init';
const PASSWORD_MIN_LENGTH = 8;
const TOAST_DEFAULT_DURATION = 3000;
const REDIRECT_DELAY_SUCCESS = 1500;
const theme = {
init: () => {
const storedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
document.documentElement.dataset.theme = currentTheme;
const i18n = {
currentLocale: {},
currentLang: 'en',
// 从第一个片段引入, 使用对象更方便显示语言名称
supportedLangs: { 'en': 'English', 'zh-CN': '简体中文' },
t: function(key, replacements = {}) {
const translation = key.split('.').reduce((obj, k) => obj && obj[k], this.currentLocale) || key;
let result = translation;
if (typeof result === 'string') {
for (const placeholder in replacements) {
result = result.replace(`{${placeholder}}`, replacements[placeholder]);
}
}
return result;
},
applyTranslations: function() {
// 优化后的翻译应用逻辑, 优先更新span, 其次更新非空文本节点, 最后直接更新元素文本
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
const translation = this.t(key);
if (translation !== key) { // 仅当找到翻译时才应用
const spanChild = el.querySelector('span');
if (spanChild) {
spanChild.textContent = translation;
} else {
// 查找直接的、非空文本节点进行替换
const textNode = Array.from(el.childNodes).find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim().length > 0);
if (textNode) {
textNode.textContent = translation;
} else {
// 备用方案: 直接设置元素的textContent
el.textContent = translation;
}
};
const toast = {
_container: null,
_icons: { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' },
init: (containerElement) => {
if (!containerElement) {
console.error('Toast container element not found.');
return;
}
toast._container = containerElement;
toast._container.addEventListener('click', (e) => {
if (e.target.dataset.toastClose !== undefined) {
toast._hideToast(e.target.closest('.toast'));
}
});
// 从第一个片段引入, 处理data-i18n-title属性
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = this.t(el.dataset.i18nTitle);
});
document.title = this.t('pages.init.page_title');
},
show: (message, type = 'info', duration = TOAST_DEFAULT_DURATION) => {
if (!toast._container) {
console.error('Toast module not initialized. Container is missing.');
return;
loadLocale: async function(lang) {
try {
const response = await fetch(`/locales/${lang}.json`);
if (!response.ok) throw new Error('File not found');
this.currentLocale = await response.json();
this.currentLang = lang;
document.documentElement.lang = lang; // 设置HTML语言属性
localStorage.setItem('appLanguage', lang); // 从第一个片段引入, 保存到localStorage
} catch (e) {
console.error(`Could not load locale for ${lang}, using fallback.`, e);
this.currentLocale = {};
}
},
init: async function() {
// 从第一个片段引入, 优先使用保存的语言, 其次使用浏览器语言
const savedLang = localStorage.getItem('appLanguage');
const browserLang = navigator.language.startsWith('zh') ? 'zh-CN' : 'en';
const langToLoad = savedLang || browserLang;
await this.loadLocale(langToLoad);
this.applyTranslations();
this.populateLangOptions(); // 从第一个片段引入, 初始化语言选项列表
},
// 从第一个片段引入, 用于动态生成语言选项列表
populateLangOptions: function() {
// 清空现有选项
DOMElements.langOptionsList.innerHTML = '';
for (const [code, name] of Object.entries(this.supportedLangs)) {
const li = document.createElement('li');
li.dataset.lang = code;
li.textContent = name;
if (code === this.currentLang) {
li.classList.add('active'); // 标记当前选中语言
}
DOMElements.langOptionsList.appendChild(li);
}
}
// 移除 i18n.toggleLanguage, 因为有新的语言选择机制
};
const iconClass = toast._icons[type] || 'fa-info-circle';
// 从第二个片段完整引入toast对象
const toast = {
show: function(message, type = 'info', duration = 3000) {
if (!DOMElements.toastContainer) return;
const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' };
const toastElement = document.createElement('div');
toastElement.className = `toast ${type}`;
toastElement.innerHTML = `
<i class="toast-icon fa-solid ${iconClass}"></i>
<p class="toast-message">${message}</p>
<button class="toast-close" data-toast-close>×</button>
`;
toast._container.appendChild(toastElement);
toastElement.innerHTML = `<i class="toast-icon fa-solid ${icons[type]}"></i><p class="toast-message">${message}</p><button class="toast-close" data-toast-close>×</button>`;
DOMElements.toastContainer.appendChild(toastElement);
requestAnimationFrame(() => toastElement.classList.add('show'));
setTimeout(() => toast._hideToast(toastElement), duration);
const timeoutId = setTimeout(() => this._hide(toastElement), duration);
toastElement.querySelector('[data-toast-close]').addEventListener('click', () => {
clearTimeout(timeoutId);
this._hide(toastElement);
});
},
_hideToast: (toastElement) => {
_hide: function(toastElement) {
if (!toastElement) return;
toastElement.classList.remove('show');
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
}
};
// 从第二个片段完整引入handleInitSubmit函数
async function handleInitSubmit(e) {
e.preventDefault();
const username = DOMElements.initForm.username.value.trim();
const password = DOMElements.initForm.password.value.trim();
const confirmPassword = DOMElements.initForm.confirm_password.value.trim();
// 1. 获取并修剪输入值
const username = DOMElements.usernameInput.value.trim(); // 获取用户名并去除前后空格
const password = DOMElements.passwordInput.value.trim();
const confirmPassword = DOMElements.confirmPasswordInput.value.trim();
// 2. 添加用户名输入框的空值验证
if (username === '') {
toast.show('管理员用户名不能为空', 'error');
DOMElements.usernameInput.focus();
toast.show(i18n.t('toasts.error_username_empty'), 'error');
DOMElements.initForm.username.focus();
return;
}
// 其他密码验证逻辑不变
if (password === '') {
toast.show('密码不能为空', 'error');
DOMElements.passwordInput.focus();
toast.show(i18n.t('toasts.error_password_empty'), 'error');
DOMElements.initForm.password.focus();
return;
}
if (confirmPassword === '') {
toast.show('确认密码不能为空', 'error');
DOMElements.confirmPasswordInput.focus();
return;
}
if (password !== confirmPassword) {
toast.show('两次输入的密码不匹配', 'error');
DOMElements.passwordInput.focus();
toast.show(i18n.t('toasts.init_error_mismatch'), 'error');
DOMElements.initForm.confirm_password.focus();
return;
}
if (password.length < PASSWORD_MIN_LENGTH) {
toast.show(`密码长度至少为 ${PASSWORD_MIN_LENGTH}`, 'error');
DOMElements.passwordInput.focus();
toast.show(i18n.t('toasts.init_error_short', { minLength: PASSWORD_MIN_LENGTH }), 'error');
DOMElements.initForm.password.focus();
return;
}
DOMElements.initButton.disabled = true;
DOMElements.initButton.querySelector('span').textContent = '设置中...';
initButton.disabled = true;
initButton.querySelector('span').textContent = i18n.t('pages.init.setting_up_btn');
try {
const formData = new FormData(DOMElements.initForm);
formData.set('username', username); // 确保发送的是修剪过的用户名
formData.set('password', password); // 确保发送的是修剪过的密码
formData.delete('confirm_password');
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const response = await fetch(INIT_API_URL, {
method: 'POST',
@ -118,32 +158,57 @@ document.addEventListener('DOMContentLoaded', () => {
});
const result = await response.json();
if (response.ok) {
toast.show('管理员账户创建成功!正在跳转到登录页面...', 'success');
setTimeout(() => { window.location.href = '/login.html'; }, REDIRECT_DELAY_SUCCESS);
toast.show(i18n.t('toasts.init_success'), 'success');
setTimeout(() => { window.location.href = '/login.html'; }, 1500);
} else {
throw new Error(result.error || `初始化失败: ${response.status}`);
throw new Error(result.error || i18n.t('toasts.init_error_generic'));
}
} catch (error) {
toast.show(error.message, 'error');
DOMElements.initButton.disabled = false;
DOMElements.initButton.querySelector('span').textContent = '完成设置';
initButton.disabled = false;
initButton.querySelector('span').textContent = i18n.t('pages.init.setup_btn');
}
}
function initApp() {
theme.init();
async function initApp() {
// 主题设置逻辑
const storedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
// 初始化国际化
await i18n.init();
// 初始化表单事件监听
if (DOMElements.initForm) {
DOMElements.initButton = DOMElements.initForm.querySelector('button[type="submit"]');
toast.init(DOMElements.toastContainer);
DOMElements.initForm.addEventListener('submit', handleInitSubmit);
} else {
console.error('Init form element not found. Script may not function correctly.');
}
// 语言切换按钮事件监听 (从第一个片段引入)
if (DOMElements.langSwitcherBtn) {
DOMElements.langSwitcherBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡, 防止立即触发document的点击事件
DOMElements.langOptionsList.classList.toggle('hidden');
});
}
// 语言选项列表事件监听 (从第一个片段引入)
if (DOMElements.langOptionsList) {
DOMElements.langOptionsList.addEventListener('click', async (e) => {
const target = e.target.closest('li[data-lang]'); // 查找最近的语言li元素
if (target) {
await i18n.loadLocale(target.dataset.lang); // 加载新语言
i18n.applyTranslations(); // 应用翻译
i18n.populateLangOptions(); // 更新语言选项列表的激活状态
DOMElements.langOptionsList.classList.add('hidden'); // 隐藏列表
}
});
}
// 文档点击事件, 用于点击外部时隐藏语言选项列表 (从第一个片段引入)
document.addEventListener('click', () => {
if (DOMElements.langOptionsList && !DOMElements.langOptionsList.classList.contains('hidden')) {
DOMElements.langOptionsList.classList.add('hidden');
}
});
}
initApp();

View file

@ -10,7 +10,7 @@ const supportedLangs = ['en', 'zh-CN']; // 应用支持的语言列表
*/
async function loadLocale(lang) {
try {
const response = await fetch(`/locales/${lang}.json?v=${Date.now()}`);
const response = await fetch(`/locales/${lang}.json`);
if (!response.ok) {
throw new Error(`Language file for ${lang} not found (status: ${response.status}).`);
}
@ -49,7 +49,14 @@ function applyTranslationsToDOM() {
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.dataset.i18nTitle;
const translation = t(key);
if(translation !== key) el.title = translation;
if (translation !== key) el.title = translation;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.dataset.i18nPlaceholder;
const translation = t(key);
if (translation !== key && el.placeholder !== undefined) { // 确保元素有placeholder属性
el.placeholder = translation;
}
});
}
@ -107,3 +114,7 @@ export async function setLanguage(lang) {
window.location.reload(); // 刷新页面以应用所有翻译是最简单可靠的方式
}
}
export function getCurrentLanguage() {
return currentLang;
}

View file

@ -1,64 +1,139 @@
// js/login.js - 登录页面的独立逻辑
document.addEventListener('DOMContentLoaded', () => {
const DOMElements = {
loginForm: document.getElementById('login-form'),
toastContainer: document.getElementById('toast-container'),
langSwitcherBtn: document.getElementById('lang-switcher-btn'),
langOptionsList: document.getElementById('lang-options-list'), // 从第一个片段引入
};
const loginButton = DOMElements.loginForm.querySelector('button[type="submit"]');
const LOGIN_API_URL = '/v0/api/auth/login';
const theme = {
init: () => {
const storedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
document.documentElement.dataset.theme = currentTheme;
const i18n = {
currentLocale: {},
currentLang: 'en',
// 从第一个片段引入, 使用对象更方便显示语言名称
supportedLangs: { 'en': 'English', 'zh-CN': '简体中文' },
t: function(key, replacements = {}) {
const translation = key.split('.').reduce((obj, k) => obj && obj[k], this.currentLocale) || key;
let result = translation;
if (typeof result === 'string') {
for (const placeholder in replacements) {
result = result.replace(`{${placeholder}}`, replacements[placeholder]);
}
}
return result;
},
applyTranslations: function() {
// 优化后的翻译应用逻辑, 优先更新span, 其次更新非空文本节点, 最后直接更新元素文本
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
const translation = this.t(key);
if (translation !== key) { // 仅当找到翻译时才应用
const spanChild = el.querySelector('span');
if (spanChild) {
spanChild.textContent = translation;
} else {
// 查找直接的、非空文本节点进行替换
const textNode = Array.from(el.childNodes).find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim().length > 0);
if (textNode) {
textNode.textContent = translation;
} else {
// 备用方案: 直接设置元素的textContent
el.textContent = translation;
}
}
}
});
// 从第一个片段引入, 处理data-i18n-title属性
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = this.t(el.dataset.i18nTitle);
});
document.title = this.t('pages.login.page_title');
},
loadLocale: async function(lang) {
try {
const response = await fetch(`/locales/${lang}.json`);
if (!response.ok) throw new Error('File not found');
this.currentLocale = await response.json();
this.currentLang = lang;
document.documentElement.lang = lang; // 设置HTML语言属性
localStorage.setItem('appLanguage', lang); // 从第一个片段引入, 保存到localStorage
} catch (e) {
console.error(`Could not load locale for ${lang}, using fallback.`, e);
this.currentLocale = {};
}
},
init: async function() {
// 从第一个片段引入, 优先使用保存的语言, 其次使用浏览器语言
const savedLang = localStorage.getItem('appLanguage');
const browserLang = navigator.language.startsWith('zh') ? 'zh-CN' : 'en';
const langToLoad = savedLang || browserLang;
await this.loadLocale(langToLoad);
this.applyTranslations();
this.populateLangOptions(); // 从第一个片段引入, 初始化语言选项列表
},
// 从第一个片段引入, 用于动态生成语言选项列表
populateLangOptions: function() {
// 清空现有选项
DOMElements.langOptionsList.innerHTML = '';
for (const [code, name] of Object.entries(this.supportedLangs)) {
const li = document.createElement('li');
li.dataset.lang = code;
li.textContent = name;
if (code === this.currentLang) {
li.classList.add('active'); // 标记当前选中语言
}
DOMElements.langOptionsList.appendChild(li);
}
}
// 移除 i18n.toggleLanguage, 因为有新的语言选择机制
};
// 从第二个片段完整引入toast对象
const toast = {
show: (message, type = 'info', duration = 3000) => {
show: function(message, type = 'info', duration = 3000) {
if (!DOMElements.toastContainer) return;
const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' };
const iconClass = icons[type] || 'fa-info-circle';
const toastElement = document.createElement('div');
toastElement.className = `toast ${type}`;
toastElement.innerHTML = `<i class="toast-icon fa-solid ${iconClass}"></i><p class="toast-message">${message}</p><button class="toast-close" data-toast-close>×</button>`;
toastElement.innerHTML = `<i class="toast-icon fa-solid ${icons[type]}"></i><p class="toast-message">${message}</p><button class="toast-close" data-toast-close>×</button>`;
DOMElements.toastContainer.appendChild(toastElement);
requestAnimationFrame(() => toastElement.classList.add('show'));
const timeoutId = setTimeout(() => hideToast(toastElement), duration);
const timeoutId = setTimeout(() => this._hide(toastElement), duration);
toastElement.querySelector('[data-toast-close]').addEventListener('click', () => {
clearTimeout(timeoutId);
hideToast(toastElement);
this._hide(toastElement);
});
}
};
function hideToast(toastElement) {
},
_hide: function(toastElement) {
if (!toastElement) return;
toastElement.classList.remove('show');
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
}
};
// 从第二个片段完整引入handleLogin函数
async function handleLogin(e) {
e.preventDefault();
// 获取并确认值
const username = DOMElements.loginForm.username.value.trim();
const password = DOMElements.loginForm.password.value.trim();
if (username === '') {
toast.show('用户名不能为空', 'error');
toast.show(i18n.t('toasts.error_username_empty'), 'error');
DOMElements.loginForm.username.focus();
return;
}
if (password === '') {
toast.show('密码不能为空', 'error');
toast.show(i18n.t('toasts.error_password_empty'), 'error');
DOMElements.loginForm.password.focus();
return;
}
loginButton.disabled = true;
loginButton.querySelector('span').textContent = '登录中...';
loginButton.querySelector('span').textContent = i18n.t('pages.login.logging_in_btn');
try {
const response = await fetch(LOGIN_API_URL, {
@ -67,23 +142,57 @@ document.addEventListener('DOMContentLoaded', () => {
});
const result = await response.json();
if (response.ok) {
toast.show('登录成功,正在跳转...', 'success');
toast.show(i18n.t('toasts.login_success'), 'success');
setTimeout(() => { window.location.href = '/'; }, 500);
} else {
throw new Error(result.error || `登录失败: ${response.status}`);
throw new Error(result.error || i18n.t('toasts.login_error_generic'));
}
} catch (error) {
toast.show(error.message, 'error');
loginButton.disabled = false;
loginButton.querySelector('span').textContent = '登录';
loginButton.querySelector('span').textContent = i18n.t('pages.login.login_btn');
}
}
function init() {
theme.init();
async function initApp() {
// 主题设置逻辑
const storedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
// 初始化国际化
await i18n.init();
// 登录表单事件监听
if (DOMElements.loginForm) {
DOMElements.loginForm.addEventListener('submit', handleLogin);
}
// 语言切换按钮事件监听 (从第一个片段引入)
if (DOMElements.langSwitcherBtn) {
DOMElements.langSwitcherBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡, 防止立即触发document的点击事件
DOMElements.langOptionsList.classList.toggle('hidden');
});
}
init();
// 语言选项列表事件监听 (从第一个片段引入)
if (DOMElements.langOptionsList) {
DOMElements.langOptionsList.addEventListener('click', async (e) => {
const target = e.target.closest('li[data-lang]'); // 查找最近的语言li元素
if (target) {
await i18n.loadLocale(target.dataset.lang); // 加载新语言
i18n.applyTranslations(); // 应用翻译
i18n.populateLangOptions(); // 更新语言选项列表的激活状态
DOMElements.langOptionsList.classList.add('hidden'); // 隐藏列表
}
});
}
// 文档点击事件, 用于点击外部时隐藏语言选项列表 (从第一个片段引入)
document.addEventListener('click', () => {
if (DOMElements.langOptionsList && !DOMElements.langOptionsList.classList.contains('hidden')) {
DOMElements.langOptionsList.classList.add('hidden');
}
});
}
initApp();
});

View file

@ -1,8 +1,9 @@
// js/notifications.js - 提供Toast和Dialog两种通知
// 这个模块在初始化时需要知道容器的DOM元素
let toastContainer;
let dialogContainer;
let modalContainer; // 虽然在此文件中不直接使用, 但 init 中保留以示完整
let t; // 模块级翻译函数变量
function hideToast(toastElement) {
if (!toastElement) return;
@ -11,11 +12,14 @@ function hideToast(toastElement) {
}
export const notification = {
init: (toastEl, dialogEl) => {
init: (toastEl, dialogEl, modalEl, translator) => {
toastContainer = toastEl;
dialogContainer = dialogEl;
modalContainer = modalEl;
t = translator; // 保存从外部传入的翻译函数
},
toast: (message, type = 'info', duration = 3000) => {
if (!toastContainer) return;
const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle', warning: 'fa-exclamation-triangle' };
const iconClass = icons[type] || 'fa-info-circle';
const toastElement = document.createElement('div');
@ -29,28 +33,44 @@ export const notification = {
hideToast(toastElement);
});
},
confirm: (message) => {
confirm: (message, title = '', options = {}) => {
return new Promise(resolve => {
if (!dialogContainer || !t) {
// 如果模块未初始化, 提供一个浏览器默认的 confirm作为回退
console.warn('Notification module not initialized. Falling back to native confirm.');
resolve(window.confirm(message));
return;
}
// 使用 t 函数翻译按钮文本, 如果 options 中提供了自定义键, 则优先使用
const confirmText = options.confirmText || t('dialogs.confirm_btn');
const cancelText = options.cancelText || t('dialogs.cancel_btn');
const dialogHTML = `
<div class="dialog-box">
${title ? `<h3>${title}</h3>` : ''}
<p class="dialog-message">${message}</p>
<div class="dialog-actions">
<button class="btn btn-secondary" data-action="cancel">取消</button>
<button class="btn btn-primary" data-action="confirm">确定</button>
<button class="btn btn-secondary" data-action="cancel">${cancelText}</button>
<button class="btn btn-primary" data-action="confirm">${confirmText}</button>
</div>
</div>`;
dialogContainer.innerHTML = dialogHTML;
dialogContainer.classList.add('active');
const eventHandler = (e) => {
const actionButton = e.target.closest('[data-action]');
if (!actionButton) return;
closeDialog(actionButton.dataset.action === 'confirm');
};
const closeDialog = (result) => {
dialogContainer.removeEventListener('click', eventHandler);
dialogContainer.classList.remove('active');
setTimeout(() => { dialogContainer.innerHTML = ''; resolve(result); }, 200);
};
dialogContainer.addEventListener('click', eventHandler);
});
}

View file

@ -1,18 +1,14 @@
// js/settings.js - 设置页面的逻辑
import { theme, toast, activateNav } from './common.js';
import { initCaddyStatus } from './caddy.js'; // 导入 Caddy 状态模块
import { notification } from './notifications.js'; // 导入通知模块
const RESET_PWD_API_URL = '/v0/api/auth/resetpwd';
const LOGOUT_API_URL = '/v0/api/auth/logout';
import { initializePage } from './common.js';
import { api } from './api.js';
import { notification } from './notifications.js';
import { t, setLanguage, getCurrentLanguage } from './locale.js';
import { createCustomSelect } from './ui.js';
const DOMElements = {
resetForm: document.getElementById('reset-password-form'),
themeToggleInput: document.getElementById('theme-toggle-input'),
logoutBtn: document.getElementById('logout-btn'),
toastContainer: document.getElementById('toast-container'),
dialogContainer: document.getElementById('dialog-container'),
};
const resetButton = DOMElements.resetForm.querySelector('button[type="submit"]');
@ -20,81 +16,62 @@ async function handleResetPassword(e) {
e.preventDefault();
const newPassword = DOMElements.resetForm.new_password.value;
const confirmPassword = DOMElements.resetForm.confirm_new_password.value;
//保证字段均不为空, 用户名 密码 新密码
const currentPassword = DOMElements.resetForm.old_password.value;
const username = DOMElements.resetForm.username.value;
if (username === '') {
toast.show('用户名不能为空', 'error');
DOMElements.resetForm.username.focus();
if (!username || !currentPassword || !newPassword || !confirmPassword) {
notification.toast(t('toasts.error_all_fields_required'), 'error');
return;
}
if (currentPassword === '') {
toast.show('当前密码不能为空', 'error');
DOMElements.resetForm.old_password.focus();
return;
}
if (newPassword === '') {
notification.toast('新密码不能为空', 'error');
DOMElements.resetForm.new_password.focus();
return;
}
if (confirmPassword === '') {
notification.toast('确认新密码不能为空', 'error');
DOMElements.resetForm.confirm_new_password.focus();
return;
}
if (newPassword !== confirmPassword) {
notification.toast('新密码与确认密码不匹配', 'error');
notification.toast(t('toasts.init_error_mismatch'), 'error');
return;
}
if (newPassword.length < 8) {
notification.toast('新密码长度至少为8位', 'error');
notification.toast(t('toasts.init_error_short'), 'error');
return;
}
resetButton.disabled = true;
resetButton.querySelector('span').textContent = '重置中...';
const formData = new FormData(DOMElements.resetForm);
resetButton.querySelector('span').textContent = t('pages.settings.resetting_password_btn');
try {
const response = await fetch(RESET_PWD_API_URL, {
method: 'POST',
body: new URLSearchParams(formData),
});
const result = await response.json();
if (response.ok) {
notification.toast('密码重置成功!请重新登录。', 'success');
setTimeout(() => { window.location.href = LOGOUT_API_URL; }, 1500);
} else {
throw new Error(result.error || '重置密码失败');
}
const result = await api.post('/auth/resetpwd', new URLSearchParams(new FormData(DOMElements.resetForm)));
notification.toast(t('toasts.pwd_reset_success'), 'success');
setTimeout(() => { window.location.href = '/v0/api/auth/logout'; }, 1500);
} catch (error) {
notification.toast(error.message, 'error');
notification.toast(`${t('common.error_prefix')}: ${error.message}`, 'error');
resetButton.disabled = false;
resetButton.querySelector('span').textContent = '重置密码';
resetButton.querySelector('span').textContent = t('pages.settings.reset_password_btn');
}
}
async function handleLogout() {
if (await notification.confirm('您确定要退出登录吗?')) {
notification.toast('正在退出...', 'info');
setTimeout(() => { window.location.href = LOGOUT_API_URL; }, 500);
if (await notification.confirm(t('dialogs.logout_msg'))) {
notification.toast(t('toasts.logout_processing'), 'info');
setTimeout(() => { window.location.href = '/v0/api/auth/logout'; }, 500);
}
}
function init() {
theme.init(DOMElements.themeToggleInput);
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer);
activateNav('settings');
initCaddyStatus(); // 初始化通用Caddy状态检查
// 页面特有的初始化逻辑
function pageInit() {
const langOptions = { 'en': 'English', 'zh-CN': '简体中文' };
const langSelectOptions = Object.keys(langOptions).map(key => ({ name: langOptions[key], value: key }));
createCustomSelect('select-language', langSelectOptions, (selectedValue) => {
setLanguage(selectedValue);
});
const langSelect = document.getElementById('select-language');
if (langSelect) {
const currentLangName = langOptions[getCurrentLanguage()];
const selectedDiv = langSelect.querySelector('.select-selected');
if (selectedDiv) selectedDiv.textContent = currentLangName;
}
DOMElements.resetForm.addEventListener('submit', handleResetPassword);
DOMElements.logoutBtn.addEventListener('click', handleLogout);
}
init();
// 使用通用初始化函数
initializePage({ pageId: 'settings', pageInit: pageInit });

View file

@ -1,5 +1,13 @@
// js/ui.js - 管理所有与UI渲染和DOM操作相关的函数
// 模块级私有变量, 用于存储翻译函数
let t;
// 新增: 初始化函数, 用于接收翻译函数
export function initUI(translator) {
t = translator;
}
export const DOMElements = {
sidebar: document.getElementById('sidebar'),
menuToggleBtn: document.getElementById('menu-toggle-btn'),
@ -45,14 +53,14 @@ export function switchView(viewToShow) {
export function renderConfigList(filenames) {
DOMElements.configListContainer.innerHTML = '';
if (!filenames || filenames.length === 0) {
DOMElements.configListContainer.innerHTML = '<p>还没有任何配置,请创建一个。</p>';
DOMElements.configListContainer.innerHTML = `<p>${t('configs.no_configs')}</p>`;
return;
}
filenames.forEach(filename => {
const item = document.createElement('li');
item.className = 'config-item';
item.dataset.filename = filename;
item.innerHTML = `<span class="config-item-name">${filename}</span><div class="config-item-actions"><button class="btn-icon edit-btn" title="编辑"><i class="fa-solid fa-pen-to-square"></i></button><button class="btn-icon delete-btn" title="删除"><i class="fa-solid fa-trash-can"></i></button></div>`;
item.innerHTML = `<span class="config-item-name">${filename}</span><div class="config-item-actions"><button class="btn-icon edit-btn" title="${t('common.edit')}"><i class="fa-solid fa-pen-to-square"></i></button><button class="btn-icon delete-btn" title="${t('common.delete')}"><i class="fa-solid fa-trash-can"></i></button></div>`;
DOMElements.configListContainer.appendChild(item);
});
}
@ -61,21 +69,21 @@ export function addKeyValueInput(container, keyName, valueName, key = '', value
const div = document.createElement('div');
div.className = 'header-entry';
div.innerHTML = `
<input type="text" name="${keyName}" placeholder="Key" value="${key}">
<input type="text" name="${valueName}" placeholder="Value" value="${value}">
<button type="button" class="btn-icon" onclick="this.parentElement.remove()" title="移除此条目">
<input type="text" name="${keyName}" placeholder="${t('form.key_placeholder')}" value="${key}">
<input type="text" name="${valueName}" placeholder="${t('form.value_placeholder')}" value="${value}">
<button type="button" class="btn-icon" onclick="this.parentElement.remove()" title="${t('common.remove_item')}">
<i class="fa-solid fa-xmark"></i>
</button>`;
container.appendChild(div);
}
export function addSingleInput(container, inputName, placeholder, value = '') {
export function addSingleInput(container, inputName, placeholderKey, value = '') {
const div = document.createElement('div');
div.className = 'header-entry';
div.style.gridTemplateColumns = '1fr auto';
div.innerHTML = `
<input type="text" name="${inputName}" placeholder="${placeholder}" value="${value}">
<button type="button" class="btn-icon" onclick="this.parentElement.remove()" title="移除此上游">
<input type="text" name="${inputName}" placeholder="${t(placeholderKey)}" value="${value}">
<button type="button" class="btn-icon" onclick="this.parentElement.remove()" title="${t('common.remove_upstream')}">
<i class="fa-solid fa-xmark"></i>
</button>`;
container.appendChild(div);
@ -97,7 +105,7 @@ export function fillForm(config, originalFilename) {
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);
addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', 'form.upstream_server_placeholder', server);
});
}
DOMElements.upstreamHeadersContainer.innerHTML = '';
@ -137,21 +145,22 @@ export function updateCaddyStatusView(status, handlers) {
const dot = DOMElements.caddyStatusIndicator.querySelector('.status-dot');
const text = DOMElements.caddyStatusIndicator.querySelector('.status-text');
const buttonContainer = DOMElements.caddyActionButtonContainer;
if (!dot || !text || !buttonContainer) return;
dot.className = 'status-dot';
buttonContainer.innerHTML = '';
let statusText, dotClass;
switch (status) {
case 'running':
statusText = '运行中'; dotClass = 'running';
buttonContainer.appendChild(createButton('重载配置', 'btn-warning', handleReloadCaddy));
buttonContainer.appendChild(createButton('停止 Caddy', 'btn-danger', handleStopCaddy));
statusText = t('status.running'); dotClass = 'running';
buttonContainer.appendChild(createButton(t('caddy.reload_btn'), 'btn-warning', handleReloadCaddy));
buttonContainer.appendChild(createButton(t('caddy.stop_btn'), 'btn-danger', handleStopCaddy));
break;
case 'stopped':
statusText = '已停止'; dotClass = 'stopped';
buttonContainer.appendChild(createButton('启动 Caddy', 'btn-success', handleStartCaddy));
statusText = t('status.stopped'); dotClass = 'stopped';
buttonContainer.appendChild(createButton(t('caddy.start_btn'), 'btn-success', handleStartCaddy));
break;
case 'checking': statusText = '检查中...'; dotClass = 'checking'; break;
default: statusText = '状态未知'; dotClass = 'error'; break;
case 'checking': statusText = t('status.checking'); dotClass = 'checking'; break;
default: statusText = t('status.unknown'); dotClass = 'error'; break;
}
text.textContent = statusText;
dot.classList.add(dotClass);
@ -177,24 +186,21 @@ export function updateSegmentedControl(activeButton) {
slider.style.transform = `translateX(${activeButton.offsetLeft}px)`;
}
// 彻底重构: 使用 Promise 并管理自己的事件监听器生命周期
export function createPresetSelectionModal(presets) {
return new Promise(resolve => {
const modalContainer = DOMElements.modalContainer;
if (!modalContainer) return resolve(null); // 安全退出
if (!modalContainer) return resolve(null);
const presetItems = presets.map(p => `
<li data-preset-id="${p.id}">
<strong>${p.name}</strong>
<p>${p.description}</p>
<strong>${t(p.name_key) || p.name}</strong>
<p>${t(p.desc_key) || p.description}</p>
</li>
`).join('');
const modalHTML = `
<div class="modal-overlay"></div>
<div class="modal-box">
<header class="modal-header">
<h3>从预设填充</h3>
<h3>${t('form.fill_from_preset')}</h3>
<button class="btn-icon" data-modal-close><i class="fa-solid fa-xmark"></i></button>
</header>
<div class="modal-content">
@ -202,29 +208,22 @@ export function createPresetSelectionModal(presets) {
</div>
</div>
`;
modalContainer.innerHTML = modalHTML;
requestAnimationFrame(() => modalContainer.classList.add('active'));
const cleanupAndResolve = (value) => {
modalContainer.removeEventListener('click', eventHandler);
modalContainer.classList.remove('active');
setTimeout(() => {
modalContainer.innerHTML = '';
resolve(value);
}, 300);
setTimeout(() => { modalContainer.innerHTML = ''; resolve(value); }, 300);
};
const eventHandler = (e) => {
if (e.target.classList.contains('modal-overlay') || e.target.closest('[data-modal-close]')) {
cleanupAndResolve(null); // 用户取消
cleanupAndResolve(null);
}
const listItem = e.target.closest('li[data-preset-id]');
if (listItem) {
cleanupAndResolve(listItem.dataset.presetId); // 用户选择
cleanupAndResolve(listItem.dataset.presetId);
}
};
modalContainer.addEventListener('click', eventHandler);
});
}
@ -233,28 +232,30 @@ 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 = '无可用选项';
selectedDiv.textContent = t('common.no_options');
return;
}
options.forEach((option, index) => {
const item = document.createElement('div');
item.textContent = option;
item.dataset.value = option;
const optionText = typeof option === 'object' ? option.name : option;
const optionValue = typeof option === 'object' ? option.value : option;
item.textContent = optionText;
item.dataset.value = optionValue;
if (index === 0) {
selectedDiv.textContent = option;
hiddenInput.value = option;
selectedDiv.textContent = optionText;
hiddenInput.value = optionValue;
}
item.addEventListener('click', function(e) {
selectedDiv.textContent = this.textContent;
@ -266,6 +267,7 @@ export function createCustomSelect(containerId, options, onSelect) {
});
itemsDiv.appendChild(item);
});
selectedDiv.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.select-items.select-show').forEach(openSelect => {
@ -277,6 +279,7 @@ export function createCustomSelect(containerId, options, onSelect) {
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');

140
frontend/locales/en.json Normal file
View file

@ -0,0 +1,140 @@
{
"nav": {
"configs": "Site Configs",
"global": "Global Config",
"settings": "Panel Settings",
"logout": "Logout"
},
"status": {
"checking": "Checking...",
"running": "Running",
"stopped": "Stopped",
"unknown": "Unknown"
},
"caddy": {
"start_btn": "Start Caddy",
"stop_btn": "Stop Caddy",
"reload_btn": "Reload Config"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"append": "Append",
"replace": "Replace",
"back_to_list": "Back to list",
"error_prefix": "Error",
"loading": "Loading...",
"logout": "Logout"
},
"pages": {
"configs": {
"page_title": "Site Configs - CaddyDash",
"title": "Site Configuration",
"add_new_btn": "New Config",
"form_title_create": "Create New Config",
"form_title_edit": "Edit Config",
"listtitle": "Site Configs",
"rendered_caddyfile_title": "Rendered Caddyfile"
},
"global": {
"page_title": "Global Config - CaddyDash",
"title": "Global Caddyfile Configuration",
"description": "Changes here will overwrite your main Caddyfile and trigger a Caddy reload.",
"save_btn": "Save and Reload"
},
"settings": {
"page_title": "Panel Settings - CaddyDash",
"title": "Panel Settings",
"account_security_title": "Account Security",
"language_label": "Language",
"current_password_label": "Current Password",
"new_password_label": "New Password (min. 8 characters)",
"confirm_new_password_label": "Confirm New Password",
"reset_password_btn": "Reset Password",
"resetting_password_btn": "Resetting..."
},
"login": {
"page_title": "Login - CaddyDash",
"welcome": "Welcome to CaddyDash",
"prompt": "Please enter your credentials to continue",
"username_label": "Username",
"password_label": "Password",
"login_btn": "Login",
"logging_in_btn": "Logging in..."
},
"init": {
"page_title": "Initial Setup - CaddyDash",
"title": "Welcome to CaddyDash",
"prompt": "Please create your administrator account to complete setup",
"admin_user_label": "Admin Username",
"password_label": "Password (min. 8 characters)",
"confirm_password_label": "Confirm Password",
"setup_btn": "Complete Setup",
"setting_up_btn": "Setting up..."
}
},
"form": {
"legend_basic": "Basic Config",
"domain_label": "Primary Domain (used as filename)",
"legend_service_mode": "Service Mode",
"mode_none": "None",
"mode_rp": "Reverse Proxy",
"mode_fs": "File Server",
"legend_rp": "Reverse Proxy Config",
"upstream_addr_label": "Upstream Address",
"upstream_addr_placeholder": "e.g., 127.0.0.1:8080",
"enable_multi_upstream": "Enable Multi-Upstream Load Balancing",
"upstream_servers_label": "Upstream Server List",
"add_upstream_server_btn": "Add Upstream Server",
"upstream_server_placeholder": "e.g., 127.0.0.1:8081",
"upstream_headers_label": "Upstream Headers",
"fill_from_preset": "Fill from Preset",
"add_header_btn": "Add Header",
"legend_fs": "File Server Config",
"fs_root_label": "Root Directory Path",
"fs_root_placeholder": "e.g., /srv/www",
"enable_fs_browser": "Enable File Browser",
"legend_global_headers": "Global Headers",
"legend_features": "Additional Features",
"feature_log": "Enable Logging",
"feature_error_page": "Enable Custom Error Pages",
"feature_encode": "Enable Compression"
},
"dialogs": {
"unsaved_changes_title": "Unsaved Changes",
"unsaved_changes_msg": "You have unsaved changes. Are you sure you want to discard them?",
"delete_config_title": "Confirm Deletion",
"delete_config_msg": "Are you sure you want to delete \"{filename}\"?",
"stop_caddy_msg": "Are you sure you want to stop the Caddy instance?",
"reload_caddy_msg": "Are you sure you want to reload Caddy config?",
"logout_msg": "Are you sure you want to log out?",
"preset_fill_title": "Fill Method",
"preset_fill_msg": "How would you like to apply the preset?",
"confirm_btn": "Confirm",
"cancel_btn": "Cancel"
},
"toasts": {
"save_success": "Configuration saved successfully.",
"save_error": "Failed to save configuration: {error}",
"reload_sent": "Reload command has been sent.",
"delete_success": "Configuration deleted successfully.",
"delete_error": "Failed to delete configuration: {error}",
"load_configs_error": "Failed to load site configs: {error}",
"load_config_detail_error": "Failed to load config details: {error}",
"error_domain_empty": "Domain name cannot be empty.",
"loading_preset": "Loading preset...",
"preset_no_data": "This preset contains no data.",
"load_preset_error": "Failed to load preset: {error}",
"preset_fill_success": "Preset \"{presetName}\" filled successfully.",
"no_presets_available": "No applicable presets available for this section.",
"logout_processing": "Logging out...",
"error_username_empty": "Username cannot be empty.",
"error_password_empty": "Password cannot be empty.",
"login_success": "Login successful! Redirecting...",
"login_error_generic": "Login failed. Please check your credentials.",
"init_error_mismatch": "Passwords do not match.",
"init_error_short": "Password must be at least {minLength} characters long.",
"init_success": "Setup complete! Redirecting to login...",
"init_error_generic": "Initialization failed. Please try again."
}
}

140
frontend/locales/zh-CN.json Normal file
View file

@ -0,0 +1,140 @@
{
"nav": {
"configs": "站点配置",
"global": "全局配置",
"settings": "面板设置",
"logout": "退出登录"
},
"status": {
"checking": "检查中...",
"running": "运行中",
"stopped": "已停止",
"unknown": "状态未知"
},
"caddy": {
"start_btn": "启动 Caddy",
"stop_btn": "停止 Caddy",
"reload_btn": "重载配置"
},
"common": {
"save": "保存",
"cancel": "取消",
"append": "追加",
"replace": "替换",
"back_to_list": "返回列表",
"error_prefix": "错误",
"loading": "加载中...",
"logout": "退出登录"
},
"pages": {
"configs": {
"page_title": "站点配置 - CaddyDash",
"title": "站点配置",
"add_new_btn": "创建新配置",
"form_title_create": "创建新配置",
"form_title_edit": "编辑配置",
"listtitle": "站点配置管理",
"rendered_caddyfile_title": "渲染后的 Caddyfile"
},
"global": {
"page_title": "全局配置 - CaddyDash",
"title": "全局 Caddyfile 配置",
"description": "修改这些配置将会重写您的主 Caddyfile 并触发 Caddy 重载",
"save_btn": "保存并重载"
},
"settings": {
"page_title": "面板设置 - CaddyDash",
"title": "面板设置",
"account_security_title": "账户安全",
"language_label": "语言",
"current_password_label": "当前密码",
"new_password_label": "新密码 (至少8位)",
"confirm_new_password_label": "确认新密码",
"reset_password_btn": "重置密码",
"resetting_password_btn": "重置中..."
},
"login": {
"page_title": "登录 - CaddyDash",
"welcome": "欢迎使用 CaddyDash",
"prompt": "请输入您的凭证以继续",
"username_label": "用户名",
"password_label": "密码",
"login_btn": "登录",
"logging_in_btn": "登录中..."
},
"init": {
"page_title": "首次设置 - CaddyDash",
"title": "欢迎使用 CaddyDash",
"prompt": "请创建您的管理员账户以完成首次设置",
"admin_user_label": "管理员用户名",
"password_label": "密码 (至少8位)",
"confirm_password_label": "确认密码",
"setup_btn": "完成设置",
"setting_up_btn": "设置中..."
}
},
"form": {
"legend_basic": "基础配置",
"domain_label": "主域名 (将用作文件名)",
"legend_service_mode": "服务模式",
"mode_none": "无",
"mode_rp": "反向代理",
"mode_fs": "文件服务",
"legend_rp": "反向代理配置",
"upstream_addr_label": "上游服务地址",
"upstream_addr_placeholder": "例如: 127.0.0.1:8080",
"enable_multi_upstream": "启用多上游负载均衡",
"upstream_servers_label": "上游服务器列表",
"add_upstream_server_btn": "添加上游服务器",
"upstream_server_placeholder": "例如: 127.0.0.1:8081",
"upstream_headers_label": "上游请求头 (Upstream Headers)",
"fill_from_preset": "从预设填充",
"add_header_btn": "添加请求头",
"legend_fs": "文件服务配置",
"fs_root_label": "根目录路径",
"fs_root_placeholder": "例如: /srv/www",
"enable_fs_browser": "启用文件浏览器",
"legend_global_headers": "全局请求头 (Headers)",
"legend_features": "附加功能",
"feature_log": "启用日志",
"feature_error_page": "启用错误页",
"feature_encode": "启用压缩"
},
"dialogs": {
"unsaved_changes_title": "未保存的更改",
"unsaved_changes_msg": "您有未保存的更改确定要放弃吗?",
"delete_config_title": "确认删除",
"delete_config_msg": "您确定要删除 \"{filename}\" 吗?",
"stop_caddy_msg": "您确定要停止 Caddy 实例吗?",
"reload_caddy_msg": "确定要重载 Caddy 配置吗?",
"logout_msg": "您确定要退出登录吗?",
"preset_fill_title": "填充方式",
"preset_fill_msg": "您希望如何应用这个预设?",
"confirm_btn": "确定",
"cancel_btn": "取消"
},
"toasts": {
"save_success": "配置已成功保存",
"save_error": "保存配置失败: {error}",
"reload_sent": "重载命令已发送",
"delete_success": "配置已成功删除",
"delete_error": "删除配置失败: {error}",
"load_configs_error": "加载站点配置列表失败: {error}",
"load_config_detail_error": "加载配置详情失败: {error}",
"error_domain_empty": "域名不能为空",
"loading_preset": "正在加载预设...",
"preset_no_data": "此预设无数据",
"load_preset_error": "加载预设失败: {error}",
"preset_fill_success": "已成功填充预设 \"{presetName}\"",
"no_presets_available": "没有适用于此区域的预设",
"logout_processing": "正在退出...",
"error_username_empty": "用户名不能为空",
"error_password_empty": "密码不能为空",
"login_success": "登录成功!正在跳转...",
"login_error_generic": "登录失败请检查您的凭证",
"init_error_mismatch": "两次输入的密码不一致",
"init_error_short": "密码至少需要 {minLength} 位字符",
"init_success": "设置完成!正在跳转到登录页...",
"init_error_generic": "初始化失败,请重试"
}
}

View file

@ -1,44 +1,56 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - CaddyDash</title>
<title data-i18n="pages.login.page_title">登录 - CaddyDash</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body class="login-page-body">
<div class="login-container">
<header class="login-header">
<i class="fa-solid fa-rocket"></i>
<h1>CaddyDash</h1>
<p>请输入您的凭证以继续</p>
<h1 data-i18n="pages.login.welcome">欢迎使用 CaddyDash</h1>
<p data-i18n="pages.login.prompt">请输入您的凭证以继续</p>
</header>
<form id="login-form">
<div class="form-group">
<label for="username">用户名</label>
<label for="username" data-i18n="pages.login.username_label">用户名</label>
<input type="text" id="username" name="username" autocomplete="username">
</div>
<div class="form-group">
<label for="password">密码</label>
<label for="password" data-i18n="pages.login.password_label">密码</label>
<input type="password" id="password" name="password" autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-login">
<span>登录</span>
<span data-i18n="pages.login.login_btn">登录</span>
<i class="fa-solid fa-arrow-right"></i>
</button>
</form>
</div>
<!-- 右上角消息通知容器 -->
<div id="toast-container" class="toast-container"></div>
<!-- 新增: 语言切换器 -->
<div class="language-switcher-corner">
<button id="lang-switcher-btn" class="btn-icon" data-i18n-title="common.switch_language" title="切换语言">
<i class="fa-solid fa-language"></i>
</button>
<!-- 语言选项列表, 默认隐藏 -->
<ul id="lang-options-list" class="hidden">
<!-- JS 动态填充 -->
</ul>
</div>
<script src="js/login.js"></script>
<div id="toast-container" class="toast-container"></div>
<script type="module" src="js/login.js"></script>
</body>
</html>

View file

@ -22,7 +22,8 @@
<nav class="sidebar-nav">
<ul>
<li><a href="/" data-nav-id="configs"><i class="fa-solid fa-sitemap"></i> <span>配置管理</span></a></li>
<li><a href="/global.html" data-nav-id="global"><i class="fa-solid fa-globe"></i> <span>全局配置</span></a></li>
<li><a href="/global.html" data-nav-id="global"><i class="fa-solid fa-globe"></i>
<span>全局配置</span></a></li>
<li><a href="/settings.html" data-nav-id="settings"><i class="fa-solid fa-gears"></i>
<span>面板设置</span></a></li>
</ul>
@ -48,6 +49,13 @@
</header>
<div id="view-container">
<section class="card-panel">
<h3 data-i18n="settings_language_title">界面设置</h3>
<div class="form-group" style="margin-top: 16px;">
<label for="select-language" data-i18n="pages.settings.language_label">语言</label>
<div id="select-language" class="custom-select"></div>
</div>
</section>
<section class="card-panel">
<h3>账户安全</h3>
<form id="reset-password-form" style="margin-top: 24px;">

2
go.mod
View file

@ -4,9 +4,11 @@ go 1.24.4
require (
github.com/BurntSushi/toml v1.5.0
github.com/fenthope/compress v0.0.3
github.com/fenthope/record v0.0.3
github.com/fenthope/sessions v0.0.1
github.com/infinite-iroha/touka v0.2.2
github.com/klauspost/compress v1.18.0
golang.org/x/crypto v0.39.0
modernc.org/sqlite v1.38.0
)

4
go.sum
View file

@ -6,6 +6,8 @@ github.com/WJQSERVER-STUDIO/httpc v0.7.1 h1:D3NlfY52pwKIOSzkdRrLinUynyKELrcPZEO8
github.com/WJQSERVER-STUDIO/httpc v0.7.1/go.mod h1:M7KNUZjjhCkzzcg9lBPs9YfkImI+7vqjAyjdA19+joE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fenthope/compress v0.0.3 h1:HerAPZjRwpXzhnC5iunUE0rb1CtcDkAvQHNtKtLH5Ec=
github.com/fenthope/compress v0.0.3/go.mod h1:/3+aXXRWs9HOOf7fe1m4UhV04/aHco8YxuxeXJeWlzE=
github.com/fenthope/reco v0.0.3 h1:RmnQ0D9a8PWtwOODawitTe4BztTnS9wYwrDbipISNq4=
github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y=
github.com/fenthope/record v0.0.3 h1:v5urgs5LAkLMlljAT/MjW8fWuRHXPnAraTem5ui7rm4=
@ -26,6 +28,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=

33
main.go
View file

@ -15,10 +15,12 @@ import (
"os"
"time"
"github.com/fenthope/compress"
"github.com/fenthope/record"
"github.com/fenthope/sessions"
"github.com/fenthope/sessions/cookie"
"github.com/infinite-iroha/touka"
"github.com/klauspost/compress/zstd"
_ "modernc.org/sqlite"
)
@ -117,6 +119,37 @@ func main() {
r := touka.Default()
r.Use(record.Middleware())
r.Use(compress.Compression(compress.CompressOptions{
// Algorithms: 配置每种压缩算法的级别和是否启用对象池
Algorithms: map[string]compress.AlgorithmConfig{
compress.EncodingGzip: {
Level: -1, // Gzip最高压缩比
PoolEnabled: true, // 启用Gzip压缩器的对象池
},
compress.EncodingDeflate: {
Level: -1, // Deflate默认压缩比
PoolEnabled: false, // Deflate不启用对象池
},
compress.EncodingZstd: {
Level: int(zstd.SpeedBestCompression), // Zstandard最佳压缩比
PoolEnabled: true, // 启用Zstandard压缩器的对象池
},
},
// MinContentLength: 响应内容达到此字节数才进行压缩 (例如 1KB)
MinContentLength: 512,
// CompressibleTypes: 只有响应的 Content-Type 匹配此列表中的MIME类型前缀才进行压缩
CompressibleTypes: compress.DefaultCompressibleTypes,
// EncodingPriority: 当客户端接受多种支持的压缩算法时,服务器选择的优先级顺序
EncodingPriority: []string{
compress.EncodingZstd,
compress.EncodingGzip,
compress.EncodingDeflate,
},
}))
store := cookie.NewStore(sessionKey)
store.Options(sessions.Options{
Path: "/",