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

View file

@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
@ -14,18 +14,18 @@
<body> <body>
<div class="app-container"> <div class="app-container">
<!-- 侧边栏 (保持不变) -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<header class="sidebar-header"><i class="fa-solid fa-rocket"></i> <header class="sidebar-header"><i class="fa-solid fa-rocket"></i>
<h1>CaddyDash</h1> <h1>CaddyDash</h1>
</header> </header>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<ul> <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> <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> <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> </ul>
</nav> </nav>
<div class="sidebar-bottom"> <div class="sidebar-bottom">
@ -34,96 +34,98 @@
</div> </div>
<div class="caddy-control-panel"> <div class="caddy-control-panel">
<div id="caddy-status-indicator" class="caddy-status"><span class="status-dot checking"></span><span <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 id="caddy-action-button-container"></div>
</div> </div>
<div class="logout-section"><button id="logout-btn" class="btn btn-secondary"><i <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> </div>
</aside> </aside>
<main class="main-content"> <main class="main-content">
<header class="main-header"> <header class="main-header">
<button class="btn-icon" id="menu-toggle-btn"><i class="fa-solid fa-bars"></i></button> <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> </header>
<div id="view-container"> <div id="view-container">
<section class="card-panel"> <section class="card-panel">
<p class="text-secondary" style="margin-top:-1rem; margin-bottom: 2rem;">修改这些配置将会重写您的主 Caddyfile 并触发 <p class="text-secondary" style="margin-top:-1rem; margin-bottom: 2rem;"
Caddy 重载。</p> data-i18n="pages.global.description">修改这些配置将会重写您的主 Caddyfile 并触发 Caddy 重载。</p>
<form id="global-caddy-form"> <form id="global-caddy-form">
<fieldset> <fieldset>
<legend>通用选项</legend> <legend data-i18n="form.legend_general_options">通用选项</legend>
<div class="form-grid"> <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> 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> 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> id="https_port" name="https_port" min="1" max="65535"></div>
</div> </div>
<div class="checkbox-grid"> <div class="checkbox-grid">
<label class="custom-checkbox"><input type="checkbox" id="debug" name="debug"><span <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 <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> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>主日志配置</legend> <legend data-i18n="form.legend_main_log_config">主日志配置</legend>
<div class="form-grid"> <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 id="select-log-level" class="custom-select"></div>
</div> </div>
<div class="form-group"><label for="log_rotate_size">滚动大小</label><input type="text" <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"></div> id="log_rotate_size" name="log_rotate_size" placeholder="例如: 10MB"
<div class="form-group"><label for="log_rotate_keep">保留文件数</label><input type="text" data-i18n-placeholder="form.log_rotate_size_placeholder"></div>
id="log_rotate_keep" name="log_rotate_keep" placeholder="例如: 10"></div> <div class="form-group"><label for="log_rotate_keep" data-i18n="form.log_rotate_keep_label">保留文件数</label><input type="text"
<div class="form-group"><label for="log_rotate_keep_for_time">保留时间</label><input 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" 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> </div>
</fieldset> </fieldset>
<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" <label class="custom-checkbox"><input type="checkbox" id="enable_dns_challenge"
name="enable_dns_challenge"><span class="checkmark"></span> 启用全局 DNS name="enable_dns_challenge"><span class="checkmark"></span> <span data-i18n="form.enable_global_dns_challenge">启用全局 DNS
Challenge</label> Challenge</span></label>
<div id="global-tls-config-group" class="hidden" style="margin-top:16px;"> <div id="global-tls-config-group" class="hidden" style="margin-top:16px;">
<div class="form-grid"> <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 id="select-tls-provider" class="custom-select"></div>
</div> </div>
<div class="form-group"><label for="tls_email">ACME 邮箱</label><input type="email" <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="用于证书申请和续订通知"></div> id="tls_email" name="tls_email" placeholder="用于证书申请和续订通知"
<div class="form-group"><label for="tls_token">API Token (或等效凭证)</label><input 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"> type="password" id="tls_token" name="tls_token" autocomplete="new-password">
</div> </div>
</div> </div>
</div> </div>
</fieldset> </fieldset>
<!-- 新增: ECH 配置区域 -->
<fieldset> <fieldset>
<legend>加密客户端问候 (ECH)</legend> <legend data-i18n="form.legend_ech_config">加密客户端问候 (ECH)</legend>
<label class="custom-checkbox"><input type="checkbox" id="enable_ech" <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 id="ech-config-group" class="hidden" style="margin-top:16px;">
<div class="form-group"> <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" <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>
</div> </div>
</fieldset> </fieldset>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-save"></i> <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> </div>
</form> </form>
</section> </section>
@ -133,6 +135,7 @@
<div id="toast-container" class="toast-container"></div> <div id="toast-container" class="toast-container"></div>
<div id="dialog-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/common.js"></script>
<script type="module" src="js/global.js"></script> <script type="module" src="js/global.js"></script>

View file

@ -1,15 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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 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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
@ -19,26 +21,35 @@
</header> </header>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<ul> <ul>
<li><a href="/" data-nav-id="configs" class="active"><i class="fa-solid fa-sitemap"></i> <span>站点配置</span></a></li> <li><a href="/" data-nav-id="configs" class="active"><i class="fa-solid fa-sitemap"></i> <span
<li><a href="/global.html" data-nav-id="global"><i class="fa-solid fa-globe"></i> <span>全局配置</span></a></li> data-i18n="nav.configs">站点配置</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="/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> </ul>
</nav> </nav>
<div class="sidebar-bottom"> <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 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 id="caddy-action-button-container"></div>
</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> </div>
</aside> </aside>
<main class="main-content"> <main class="main-content">
<header class="main-header"> <header class="main-header">
<button class="btn-icon" id="menu-toggle-btn"><i class="fa-solid fa-bars"></i></button> <button class="btn-icon" id="menu-toggle-btn"><i class="fa-solid fa-bars"></i></button>
<h2>配置管理</h2> <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">创建新配置</span></button> <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> </header>
<div id="view-container"> <div id="view-container">
@ -47,104 +58,123 @@
</section> </section>
<section id="config-form-panel" class="card-panel view hidden"> <section id="config-form-panel" class="card-panel view hidden">
<div class="form-panel-header"> <div class="form-panel-header">
<button id="back-to-list-btn" class="btn-icon" title="返回列表"><i class="fa-solid fa-arrow-left"></i></button> <button id="back-to-list-btn" class="btn-icon" title="返回列表"
<h3 id="form-title">创建新配置</h3> 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> </div>
<form id="config-form"> <form id="config-form">
<input type="hidden" id="original-filename" value=""> <input type="hidden" id="original-filename" value="">
<fieldset> <fieldset>
<legend>基础配置</legend> <legend data-i18n="form.legend_basic">基础配置</legend>
<div class="form-group"> <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> <input type="text" id="domain" name="domain" required>
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>服务模式</legend> <legend data-i18n="form.legend_service_mode">服务模式</legend>
<div id="service-mode-control" class="segmented-control"> <div id="service-mode-control" class="segmented-control">
<div id="segmented-control-slider"></div> <div id="segmented-control-slider"></div>
<button type="button" data-mode="none" class="active"></button> <button type="button" data-mode="none" class="active"
<button type="button" data-mode="reverse_proxy">反向代理</button> data-i18n="form.mode_none">无</button>
<button type="button" data-mode="file_server">文件服务</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> </div>
</fieldset> </fieldset>
<fieldset id="upstream-fieldset" class="hidden"> <fieldset id="upstream-fieldset" class="hidden">
<legend>反向代理配置</legend> <legend data-i18n="form.legend_rp">反向代理配置</legend>
<div class="form-group" id="single-upstream-group"> <div class="form-group" id="single-upstream-group">
<label for="upstream">上游服务地址</label> <label for="upstream" data-i18n="form.upstream_addr_label">上游服务地址</label>
<input type="text" id="upstream" name="upstream" placeholder="例如: 127.0.0.1:8080"> <input type="text" id="upstream" name="upstream" placeholder="例如: 127.0.0.1:8080"
data-i18n-placeholder="form.upstream_addr_placeholder">
</div> </div>
<div class="sub-fieldset"> <div class="sub-fieldset">
<label class="custom-checkbox"> <label class="custom-checkbox">
<input type="checkbox" id="muti_upstream" name="muti_upstream"> <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> </label>
<div id="multi-upstream-group" class="hidden" style="margin-top: 16px;"> <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> <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> </div>
<div class="sub-fieldset"> <div class="sub-fieldset">
<div class="sub-legend-group"> <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"> <button type="button" class="btn-link" data-preset-target="upstream">
<i class="fa-solid fa-wand-magic-sparkles"></i> <i class="fa-solid fa-wand-magic-sparkles"></i>
<span>从预设填充</span> <span data-i18n="form.fill_from_preset">从预设填充</span>
</button> </button>
</div> </div>
<div id="upstream-headers-container"></div> <div id="upstream-headers-container"></div>
<button type="button" class="btn btn-secondary btn-small" data-add-target="upstream"> <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> </button>
</div> </div>
</fieldset> </fieldset>
<fieldset id="fileserver-fieldset" class="hidden"> <fieldset id="fileserver-fieldset" class="hidden">
<legend>文件服务配置</legend> <legend data-i18n="form.legend_fs">文件服务配置</legend>
<div class="form-group"> <div class="form-group">
<label for="file_dir_path">根目录路径</label> <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"> <input type="text" id="file_dir_path" name="file_dir_path" placeholder="例如: /srv/www"
data-i18n-placeholder="form.fs_root_placeholder">
</div> </div>
<label class="custom-checkbox"> <label class="custom-checkbox">
<input type="checkbox" id="enable_browser" name="enable_browser"> <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> </label>
</fieldset> </fieldset>
<fieldset id="headers-fieldset"> <fieldset id="headers-fieldset">
<div class="sub-legend-group"> <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"> <button type="button" class="btn-link" data-preset-target="global">
<i class="fa-solid fa-wand-magic-sparkles"></i> <i class="fa-solid fa-wand-magic-sparkles"></i>
<span>从预设填充</span> <span data-i18n="form.fill_from_preset">从预设填充</span>
</button> </button>
</div> </div>
<div id="headers-container"></div> <div id="headers-container"></div>
<button type="button" class="btn btn-secondary btn-small" data-add-target="global"> <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> </button>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>附加功能</legend> <legend data-i18n="form.legend_features">附加功能</legend>
<div class="checkbox-grid"> <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_log"
<label class="custom-checkbox"><input type="checkbox" id="enable_error_page" name="enable_error_page"><span class="checkmark"></span> 启用错误页</label> name="enable_log"><span class="checkmark"></span> <span
<label class="custom-checkbox"><input type="checkbox" id="enable_encode" name="enable_encode"><span class="checkmark"></span> 启用压缩</label> 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> </div>
</fieldset> </fieldset>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-save"></i> <span>保存配置</span></button> <button type="submit" class="btn btn-primary"><i class="fa-solid fa-save"></i> <span
<button type="button" id="cancel-edit-btn" class="btn btn-secondary">取消</button> data-i18n="common.save">保存配置</span></button>
<button type="button" id="cancel-edit-btn" class="btn btn-secondary"
data-i18n="common.cancel">取消</button>
</div> </div>
</form> </form>
</section> </section>
<section id="rendered-output-panel" class="card-panel view hidden"> <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> <pre><code id="rendered-content"></code></pre>
</section> </section>
</div> </div>
@ -153,8 +183,9 @@
<div id="toast-container" class="toast-container"></div> <div id="toast-container" class="toast-container"></div>
<div id="dialog-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> <script type="module" src="js/app.js"></script>
</body> </body>
</html> </html>

View file

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

View file

@ -2,13 +2,12 @@
import { state } from './state.js'; import { state } from './state.js';
import { api } from './api.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 { 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'; import { DOMElements, switchView, renderConfigList, addKeyValueInput, addSingleInput, fillForm, showRenderedConfig, updateCaddyStatusView, updateSegmentedControl, updateServiceModeView, updateMultiUpstreamView, createPresetSelectionModal } from './ui.js';
const POLLING_INTERVAL = 5000; // --- 事件处理与逻辑流 (所有 handle* 函数保持不变) ---
let caddyStatusInterval;
function getFormStateAsString() { function getFormStateAsString() {
const formData = new FormData(DOMElements.configForm); const formData = new FormData(DOMElements.configForm);
@ -27,14 +26,18 @@ function getFormStateAsString() {
} }
async function attemptExitForm() { async function attemptExitForm() {
if (getFormStateAsString() !== state.initialFormState) { if (await getFormStateAsString() !== state.initialFormState) {
if (await notification.confirm('您有未保存的更改。确定要放弃吗?')) switchView(DOMElements.configListPanel); if (await notification.confirm(t('dialogs.unsaved_changes_msg'), t('dialogs.unsaved_changes_title'))) {
} else switchView(DOMElements.configListPanel); switchView(DOMElements.configListPanel);
}
} else {
switchView(DOMElements.configListPanel);
}
} }
async function handleLogout() { async function handleLogout() {
if (!await notification.confirm('您确定要退出登录吗?')) return; if (!await notification.confirm(t('dialogs.logout_msg'))) return;
notification.toast('正在退出...', 'info'); notification.toast(t('toasts.logout_processing'), 'info');
setTimeout(() => { window.location.href = `/v0/api/auth/logout`; }, 500); setTimeout(() => { window.location.href = `/v0/api/auth/logout`; }, 500);
} }
@ -42,7 +45,7 @@ async function loadAllConfigs() {
try { try {
const filenames = await api.get('/config/filenames'); const filenames = await api.get('/config/filenames');
renderConfigList(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) { async function handleEditConfig(originalFilename) {
@ -53,22 +56,22 @@ async function handleEditConfig(originalFilename) {
]); ]);
state.isEditing = true; state.isEditing = true;
switchView(DOMElements.configFormPanel); switchView(DOMElements.configFormPanel);
DOMElements.formTitle.textContent = '编辑配置'; DOMElements.formTitle.textContent = t('pages.configs.form_title_edit');
fillForm(config, originalFilename); fillForm(config, originalFilename);
showRenderedConfig(rendered, originalFilename); showRenderedConfig(rendered, originalFilename);
const mode = config.upstream_config?.enable_upstream ? 'reverse_proxy' : (config.file_server_config?.enable_file_server ? 'file_server' : 'none'); const mode = config.upstream_config?.enable_upstream ? 'reverse_proxy' : (config.file_server_config?.enable_file_server ? 'file_server' : 'none');
updateServiceModeView(mode); updateServiceModeView(mode);
state.initialFormState = getFormStateAsString(); state.initialFormState = await getFormStateAsString();
} catch (error) { notification.toast(`加载配置详情失败: ${error.message}`, 'error'); } } catch (error) { notification.toast(t('toasts.load_config_detail_error', { error: error.message }), 'error'); }
} }
async function handleDeleteConfig(filename) { 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 { try {
await api.delete(`/config/file/${filename}`); await api.delete(`/config/file/${filename}`);
notification.toast('配置已成功删除。', 'success'); notification.toast(t('toasts.delete_success'), 'success');
loadAllConfigs(); loadAllConfigs();
} catch (error) { notification.toast(`删除失败: ${error.message}`, 'error'); } } catch (error) { notification.toast(t('toasts.delete_error', { error: error.message }), 'error'); }
} }
async function handleSaveConfig(e) { async function handleSaveConfig(e) {
@ -76,7 +79,7 @@ async function handleSaveConfig(e) {
const formData = new FormData(DOMElements.configForm); const formData = new FormData(DOMElements.configForm);
const domain = formData.get('domain'); const domain = formData.get('domain');
if (!domain) { if (!domain) {
notification.toast('域名不能为空。', 'error'); notification.toast(t('toasts.error_domain_empty'), 'error');
return; return;
} }
const getHeadersMap = (keyName, valueName) => { const getHeadersMap = (keyName, valueName) => {
@ -116,23 +119,23 @@ async function handleSaveConfig(e) {
try { try {
const result = await api.put(`/config/file/${domain}`, configData); const result = await api.put(`/config/file/${domain}`, configData);
state.isEditing = false; state.isEditing = false;
notification.toast(result.message || '配置已成功保存。', 'success'); notification.toast(result.message || t('toasts.save_success'), 'success');
setTimeout(() => { setTimeout(() => {
switchView(DOMElements.configListPanel); switchView(DOMElements.configListPanel);
loadAllConfigs(); loadAllConfigs();
}, 500); }, 500);
} catch (error) { notification.toast(`保存失败: ${error.message}`, 'error'); } } catch (error) { notification.toast(t('toasts.save_error', { error: error.message }), 'error'); }
} }
async function openPresetModal(targetType) { async function openPresetModal(targetType) {
const applicablePresets = state.headerPresets.filter(p => p.target === targetType || p.target === 'any'); const applicablePresets = state.headerPresets.filter(p => p.target === targetType || p.target === 'any');
if (applicablePresets.length === 0) { if (applicablePresets.length === 0) {
notification.toast(`没有适用于此区域的预设。`, 'info'); notification.toast(t('toasts.no_presets_available'), 'info');
return; return;
} }
const presetId = await createPresetSelectionModal(applicablePresets); const presetId = await createPresetSelectionModal(applicablePresets, t);
if (!presetId) return; // 用户取消了选择 if (!presetId) return;
let targetContainer, keyName, valueName; let targetContainer, keyName, valueName;
if (targetType === 'global') { if (targetType === 'global') {
@ -147,18 +150,17 @@ async function openPresetModal(targetType) {
return; return;
} }
// 在获取到presetId后再执行填充逻辑
try { try {
notification.toast('正在加载预设...', 'info', 1000); notification.toast(t('toasts.loading_preset'), 'info', 1000);
const preset = await api.get(`/config/headers-presets/${presetId}`); const preset = await api.get(`/config/headers-presets/${presetId}`);
if (!preset.headers) { if (!preset.headers) {
notification.toast('此预设无数据。', 'info'); notification.toast(t('toasts.preset_no_data'), 'info');
return; 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 = ''; targetContainer.innerHTML = '';
} }
@ -167,19 +169,16 @@ async function openPresetModal(targetType) {
addKeyValueInput(targetContainer, keyName, valueName, key, value); addKeyValueInput(targetContainer, keyName, valueName, key, value);
}); });
}); });
notification.toast(`已成功填充预设 "${preset.name}"`, 'success'); notification.toast(t('toasts.preset_fill_success', { presetName: preset.name }), 'success');
} catch (error) { } catch (error) {
notification.toast(`加载预设失败: ${error.message}`, 'error'); notification.toast(t('toasts.load_preset_error', { error: error.message }), 'error');
} }
} }
function init() { // --- 初始化与事件绑定 ---
theme.init(DOMElements.themeToggleInput); function pageInit() {
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer, DOMElements.modalContainer); // 这个函数包含所有特定于 app.js 的初始化逻辑
activateNav('configs');
initCaddyStatus();
loadAllConfigs(); loadAllConfigs();
api.get('/config/headers-presets') api.get('/config/headers-presets')
@ -187,16 +186,13 @@ function init() {
state.headerPresets = presets || []; state.headerPresets = presets || [];
}) })
.catch(err => { .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.addNewConfigBtn.addEventListener('click', async () => {
DOMElements.mainContent.addEventListener('click', () => DOMElements.sidebar.classList.remove('is-open'));
DOMElements.addNewConfigBtn.addEventListener('click', () => {
state.isEditing = false; state.isEditing = false;
switchView(DOMElements.configFormPanel); switchView(DOMElements.configFormPanel);
DOMElements.formTitle.textContent = '创建新配置'; DOMElements.formTitle.textContent = t('pages.configs.form_title_create');
DOMElements.configForm.reset(); DOMElements.configForm.reset();
const noneButton = DOMElements.serviceModeControl.querySelector('[data-mode="none"]'); const noneButton = DOMElements.serviceModeControl.querySelector('[data-mode="none"]');
@ -204,7 +200,7 @@ function init() {
updateServiceModeView('none'); updateServiceModeView('none');
updateMultiUpstreamView(false); updateMultiUpstreamView(false);
state.initialFormState = getFormStateAsString(); state.initialFormState = await getFormStateAsString();
DOMElements.headersContainer.innerHTML = ''; DOMElements.headersContainer.innerHTML = '';
DOMElements.upstreamHeadersContainer.innerHTML = ''; DOMElements.upstreamHeadersContainer.innerHTML = '';
DOMElements.multiUpstreamContainer.innerHTML = ''; DOMElements.multiUpstreamContainer.innerHTML = '';
@ -247,7 +243,7 @@ function init() {
DOMElements.addMultiUpstreamBtn.addEventListener('click', (e) => { DOMElements.addMultiUpstreamBtn.addEventListener('click', (e) => {
e.preventDefault(); 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) => { 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; let caddyStatusInterval;
const POLLING_INTERVAL = 5000; const POLLING_INTERVAL = 5000;
// 将 t 函数保存在模块作用域内
let translate;
const DOMElements = { const DOMElements = {
caddyStatusIndicator: document.getElementById('caddy-status-indicator'), caddyStatusIndicator: document.getElementById('caddy-status-indicator'),
caddyActionButtonContainer: document.getElementById('caddy-action-button-container'), caddyActionButtonContainer: document.getElementById('caddy-action-button-container'),
@ -24,23 +27,31 @@ function updateCaddyStatusView(status) {
const text = DOMElements.caddyStatusIndicator.querySelector('.status-text'); const text = DOMElements.caddyStatusIndicator.querySelector('.status-text');
const buttonContainer = DOMElements.caddyActionButtonContainer; const buttonContainer = DOMElements.caddyActionButtonContainer;
if(!dot || !text || !buttonContainer) return; // 如果元素不存在,则不执行 if(!dot || !text || !buttonContainer) return;
dot.className = 'status-dot'; dot.className = 'status-dot';
buttonContainer.innerHTML = ''; buttonContainer.innerHTML = '';
let statusText, dotClass; let statusText, dotClass;
switch (status) { switch (status) {
case 'running': case 'running':
statusText = '运行中'; dotClass = 'running'; statusText = translate('status.running');
buttonContainer.appendChild(createButton('重载配置', 'btn-warning', handleReloadCaddy)); dotClass = 'running';
buttonContainer.appendChild(createButton('停止 Caddy', 'btn-danger', handleStopCaddy)); buttonContainer.appendChild(createButton(translate('caddy.reload_btn'), 'btn-warning', handleReloadCaddy));
buttonContainer.appendChild(createButton(translate('caddy.stop_btn'), 'btn-danger', handleStopCaddy));
break; break;
case 'stopped': case 'stopped':
statusText = '已停止'; dotClass = 'stopped'; statusText = translate('status.stopped');
buttonContainer.appendChild(createButton('启动 Caddy', 'btn-success', handleStartCaddy)); 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; break;
case 'checking': statusText = '检查中...'; dotClass = 'checking'; break;
default: statusText = '状态未知'; dotClass = 'error'; break;
} }
text.textContent = statusText; text.textContent = statusText;
dot.classList.add(dotClass); dot.classList.add(dotClass);
@ -59,35 +70,38 @@ async function checkCaddyStatus() {
async function handleStartCaddy() { async function handleStartCaddy() {
try { try {
const result = await api.post('/caddy/run'); 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); 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() { async function handleStopCaddy() {
if (!await notification.confirm('您确定要停止 Caddy 实例吗?')) return; if (!await notification.confirm(translate('dialogs.stop_caddy_msg'))) return;
try { try {
const result = await api.post('/caddy/stop'); 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); 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() { async function handleReloadCaddy() {
if (!await notification.confirm('确定要重载 Caddy 配置吗?')) return; if (!await notification.confirm(translate('dialogs.reload_caddy_msg'))) return;
try { try {
const result = await api.post('/caddy/restart'); const result = await api.post('/caddy/restart');
notification.toast(result.message || '重载命令已发送。', 'success'); notification.toast(result.message || translate('toasts.reload_sent'), 'success');
setTimeout(checkCaddyStatus, 500); 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 dialogContainer = document.getElementById('dialog-container');
const toastContainer = document.getElementById('toast-container'); const toastContainer = document.getElementById('toast-container');
if (dialogContainer && toastContainer) { if (dialogContainer && toastContainer) {
notification.init(toastContainer, dialogContainer); notification.init(toastContainer, dialogContainer, null, translate); // 将 t 函数传递给通知模块
} }
checkCaddyStatus(); checkCaddyStatus();

View file

@ -1,5 +1,10 @@
// js/common.js - 存放共享模块 // 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 = { const theme = {
init: (toggleElement) => { init: (toggleElement) => {
const storedTheme = localStorage.getItem('theme'); 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 }; export { theme, toast, activateNav };

View file

@ -1,25 +1,21 @@
// js/global.js - 全局配置页面的逻辑 // js/global.js - 全局配置页面的逻辑
import { theme, activateNav } from './common.js'; import { initializePage } from './common.js'; // 导入通用初始化函数
import { api } from './api.js'; import { api } from './api.js';
import { notification } from './notifications.js'; import { notification } from './notifications.js';
import { initCaddyStatus } from './caddy.js';
import { createCustomSelect } from './ui.js'; import { createCustomSelect } from './ui.js';
const DOMElements = { const DOMElements = {
globalForm: document.getElementById('global-caddy-form'), globalForm: document.getElementById('global-caddy-form'),
themeToggleInput: document.getElementById('theme-toggle-input'),
logoutBtn: document.getElementById('logout-btn'), logoutBtn: document.getElementById('logout-btn'),
toastContainer: document.getElementById('toast-container'),
dialogContainer: document.getElementById('dialog-container'),
enableDnsChallengeCheckbox: document.getElementById('enable_dns_challenge'), enableDnsChallengeCheckbox: document.getElementById('enable_dns_challenge'),
globalTlsConfigGroup: document.getElementById('global-tls-config-group'), globalTlsConfigGroup: document.getElementById('global-tls-config-group'),
enableEchCheckbox: document.getElementById('enable_ech'), enableEchCheckbox: document.getElementById('enable_ech'),
echConfigGroup: document.getElementById('ech-config-group'), echConfigGroup: document.getElementById('ech-config-group'),
}; };
const submitButton = DOMElements.globalForm.querySelector('button[type="submit"]'); // submitButton 在 pageInit 中获取, 确保DOM已加载
let submitButton;
// 从表单收集数据, 构建成后端需要的JSON结构
function getGlobalConfigFromForm() { function getGlobalConfigFromForm() {
const formData = new FormData(DOMElements.globalForm); const formData = new FormData(DOMElements.globalForm);
const enableEch = DOMElements.enableEchCheckbox.checked; const enableEch = DOMElements.enableEchCheckbox.checked;
@ -49,7 +45,6 @@ function getGlobalConfigFromForm() {
}; };
} }
// 用从API获取的数据填充表单
function fillGlobalConfigForm(config) { function fillGlobalConfigForm(config) {
if (!config) return; if (!config) return;
@ -100,7 +95,6 @@ async function handleSaveGlobalConfig(e) {
submitButton.querySelector('span').textContent = "保存中..."; submitButton.querySelector('span').textContent = "保存中...";
try { try {
// 修正: 更新API端点路径
const result = await api.put('/global/config', configData); const result = await api.put('/global/config', configData);
notification.toast(result.message || '全局配置已成功保存Caddy正在重载...', 'success'); notification.toast(result.message || '全局配置已成功保存Caddy正在重载...', 'success');
} catch (error) { } catch (error) {
@ -118,23 +112,19 @@ async function handleLogout() {
} }
} }
function init() { // 页面特有的初始化逻辑
theme.init(DOMElements.themeToggleInput); function pageInit() {
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer); // 在这里获取 submitButton, 确保 DOM 已加载
activateNav('global'); submitButton = DOMElements.globalForm.querySelector('button[type="submit"]');
initCaddyStatus();
// 修正: 更新API端点路径
api.get('/global/log/levels') api.get('/global/log/levels')
.then(levels => createCustomSelect('select-log-level', Object.keys(levels))) .then(levels => createCustomSelect('select-log-level', Object.keys(levels)))
.catch(err => notification.toast(`加载日志级别失败: ${err.message}`, 'error')); .catch(err => notification.toast(`加载日志级别失败: ${err.message}`, 'error'));
// 修正: 更新API端点路径
api.get('/global/tls/providers') api.get('/global/tls/providers')
.then(providers => createCustomSelect('select-tls-provider', Object.keys(providers))) .then(providers => createCustomSelect('select-tls-provider', Object.keys(providers)))
.catch(err => notification.toast(`加载TLS提供商失败: ${err.message}`, 'error')); .catch(err => notification.toast(`加载TLS提供商失败: ${err.message}`, 'error'));
// 修正: 更新API端点路径
api.get('/global/config') api.get('/global/config')
.then(config => fillGlobalConfigForm(config)) .then(config => fillGlobalConfigForm(config))
.catch(err => notification.toast(`加载全局配置失败: ${err.message}`, 'error')); .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', () => { document.addEventListener('DOMContentLoaded', () => {
const DOMElements = { const DOMElements = {
initForm: document.getElementById('init-form'), initForm: document.getElementById('init-form'),
initButton: null,
toastContainer: document.getElementById('toast-container'), toastContainer: document.getElementById('toast-container'),
usernameInput: document.getElementById('username'), langSwitcherBtn: document.getElementById('lang-switcher-btn'),
passwordInput: document.getElementById('password'), langOptionsList: document.getElementById('lang-options-list'), // 从第一个片段引入
confirmPasswordInput: document.getElementById('confirm_password'),
}; };
const initButton = DOMElements.initForm.querySelector('button[type="submit"]');
const INIT_API_URL = '/v0/api/auth/init'; const INIT_API_URL = '/v0/api/auth/init';
const PASSWORD_MIN_LENGTH = 8; const PASSWORD_MIN_LENGTH = 8;
const TOAST_DEFAULT_DURATION = 3000;
const REDIRECT_DELAY_SUCCESS = 1500;
const theme = { const i18n = {
init: () => { currentLocale: {},
const storedTheme = localStorage.getItem('theme'); currentLang: 'en',
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; // 从第一个片段引入, 使用对象更方便显示语言名称
const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light'); supportedLangs: { 'en': 'English', 'zh-CN': '简体中文' },
document.documentElement.dataset.theme = currentTheme; 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');
}, },
loadLocale: async function(lang) {
show: (message, type = 'info', duration = TOAST_DEFAULT_DURATION) => { try {
if (!toast._container) { const response = await fetch(`/locales/${lang}.json`);
console.error('Toast module not initialized. Container is missing.'); if (!response.ok) throw new Error('File not found');
return; 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'); const toastElement = document.createElement('div');
toastElement.className = `toast ${type}`; toastElement.className = `toast ${type}`;
toastElement.innerHTML = ` 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>`;
<i class="toast-icon fa-solid ${iconClass}"></i> DOMElements.toastContainer.appendChild(toastElement);
<p class="toast-message">${message}</p>
<button class="toast-close" data-toast-close>×</button>
`;
toast._container.appendChild(toastElement);
requestAnimationFrame(() => toastElement.classList.add('show')); 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);
});
}, },
_hide: function(toastElement) {
_hideToast: (toastElement) => {
if (!toastElement) return; if (!toastElement) return;
toastElement.classList.remove('show'); toastElement.classList.remove('show');
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true }); toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
} }
}; };
// 从第二个片段完整引入handleInitSubmit函数
async function handleInitSubmit(e) { async function handleInitSubmit(e) {
e.preventDefault(); 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 === '') { if (username === '') {
toast.show('管理员用户名不能为空', 'error'); toast.show(i18n.t('toasts.error_username_empty'), 'error');
DOMElements.usernameInput.focus(); DOMElements.initForm.username.focus();
return; return;
} }
// 其他密码验证逻辑不变
if (password === '') { if (password === '') {
toast.show('密码不能为空', 'error'); toast.show(i18n.t('toasts.error_password_empty'), 'error');
DOMElements.passwordInput.focus(); DOMElements.initForm.password.focus();
return; return;
} }
if (confirmPassword === '') {
toast.show('确认密码不能为空', 'error');
DOMElements.confirmPasswordInput.focus();
return;
}
if (password !== confirmPassword) { if (password !== confirmPassword) {
toast.show('两次输入的密码不匹配', 'error'); toast.show(i18n.t('toasts.init_error_mismatch'), 'error');
DOMElements.passwordInput.focus(); DOMElements.initForm.confirm_password.focus();
return; return;
} }
if (password.length < PASSWORD_MIN_LENGTH) { if (password.length < PASSWORD_MIN_LENGTH) {
toast.show(`密码长度至少为 ${PASSWORD_MIN_LENGTH}`, 'error'); toast.show(i18n.t('toasts.init_error_short', { minLength: PASSWORD_MIN_LENGTH }), 'error');
DOMElements.passwordInput.focus(); DOMElements.initForm.password.focus();
return; return;
} }
DOMElements.initButton.disabled = true; initButton.disabled = true;
DOMElements.initButton.querySelector('span').textContent = '设置中...'; initButton.querySelector('span').textContent = i18n.t('pages.init.setting_up_btn');
try { try {
const formData = new FormData(DOMElements.initForm); const formData = new FormData();
formData.set('username', username); // 确保发送的是修剪过的用户名 formData.append('username', username);
formData.set('password', password); // 确保发送的是修剪过的密码 formData.append('password', password);
formData.delete('confirm_password');
const response = await fetch(INIT_API_URL, { const response = await fetch(INIT_API_URL, {
method: 'POST', method: 'POST',
@ -118,32 +158,57 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
const result = await response.json(); const result = await response.json();
if (response.ok) { if (response.ok) {
toast.show('管理员账户创建成功!正在跳转到登录页面...', 'success'); toast.show(i18n.t('toasts.init_success'), 'success');
setTimeout(() => { window.location.href = '/login.html'; }, REDIRECT_DELAY_SUCCESS); setTimeout(() => { window.location.href = '/login.html'; }, 1500);
} else { } else {
throw new Error(result.error || `初始化失败: ${response.status}`); throw new Error(result.error || i18n.t('toasts.init_error_generic'));
} }
} catch (error) { } catch (error) {
toast.show(error.message, 'error'); toast.show(error.message, 'error');
DOMElements.initButton.disabled = false; initButton.disabled = false;
DOMElements.initButton.querySelector('span').textContent = '完成设置'; initButton.querySelector('span').textContent = i18n.t('pages.init.setup_btn');
} }
} }
function initApp() { async function initApp() {
theme.init(); // 主题设置逻辑
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) { if (DOMElements.initForm) {
DOMElements.initButton = DOMElements.initForm.querySelector('button[type="submit"]');
toast.init(DOMElements.toastContainer);
DOMElements.initForm.addEventListener('submit', handleInitSubmit); 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(); initApp();

View file

@ -10,7 +10,7 @@ const supportedLangs = ['en', 'zh-CN']; // 应用支持的语言列表
*/ */
async function loadLocale(lang) { async function loadLocale(lang) {
try { try {
const response = await fetch(`/locales/${lang}.json?v=${Date.now()}`); const response = await fetch(`/locales/${lang}.json`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Language file for ${lang} not found (status: ${response.status}).`); throw new Error(`Language file for ${lang} not found (status: ${response.status}).`);
} }
@ -51,6 +51,13 @@ function applyTranslationsToDOM() {
const translation = t(key); 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(); // 刷新页面以应用所有翻译是最简单可靠的方式 window.location.reload(); // 刷新页面以应用所有翻译是最简单可靠的方式
} }
} }
export function getCurrentLanguage() {
return currentLang;
}

View file

@ -1,64 +1,139 @@
// js/login.js - 登录页面的独立逻辑
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const DOMElements = { const DOMElements = {
loginForm: document.getElementById('login-form'), loginForm: document.getElementById('login-form'),
toastContainer: document.getElementById('toast-container'), 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 loginButton = DOMElements.loginForm.querySelector('button[type="submit"]');
const LOGIN_API_URL = '/v0/api/auth/login'; const LOGIN_API_URL = '/v0/api/auth/login';
const theme = { const i18n = {
init: () => { currentLocale: {},
const storedTheme = localStorage.getItem('theme'); currentLang: 'en',
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; // 从第一个片段引入, 使用对象更方便显示语言名称
const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light'); supportedLangs: { 'en': 'English', 'zh-CN': '简体中文' },
document.documentElement.dataset.theme = currentTheme; 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 = { 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 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'); const toastElement = document.createElement('div');
toastElement.className = `toast ${type}`; 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); DOMElements.toastContainer.appendChild(toastElement);
requestAnimationFrame(() => toastElement.classList.add('show')); 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', () => { toastElement.querySelector('[data-toast-close]').addEventListener('click', () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
hideToast(toastElement); this._hide(toastElement);
}); });
} },
}; _hide: function(toastElement) {
function hideToast(toastElement) {
if (!toastElement) return; if (!toastElement) return;
toastElement.classList.remove('show'); toastElement.classList.remove('show');
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true }); toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
} }
};
// 从第二个片段完整引入handleLogin函数
async function handleLogin(e) { async function handleLogin(e) {
e.preventDefault(); e.preventDefault();
// 获取并确认值
const username = DOMElements.loginForm.username.value.trim(); const username = DOMElements.loginForm.username.value.trim();
const password = DOMElements.loginForm.password.value.trim(); const password = DOMElements.loginForm.password.value.trim();
if (username === '') { if (username === '') {
toast.show('用户名不能为空', 'error'); toast.show(i18n.t('toasts.error_username_empty'), 'error');
DOMElements.loginForm.username.focus(); DOMElements.loginForm.username.focus();
return; return;
} }
if (password === '') { if (password === '') {
toast.show('密码不能为空', 'error'); toast.show(i18n.t('toasts.error_password_empty'), 'error');
DOMElements.loginForm.password.focus(); DOMElements.loginForm.password.focus();
return; return;
} }
loginButton.disabled = true; loginButton.disabled = true;
loginButton.querySelector('span').textContent = '登录中...'; loginButton.querySelector('span').textContent = i18n.t('pages.login.logging_in_btn');
try { try {
const response = await fetch(LOGIN_API_URL, { const response = await fetch(LOGIN_API_URL, {
@ -67,23 +142,57 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
const result = await response.json(); const result = await response.json();
if (response.ok) { if (response.ok) {
toast.show('登录成功,正在跳转...', 'success'); toast.show(i18n.t('toasts.login_success'), 'success');
setTimeout(() => { window.location.href = '/'; }, 500); setTimeout(() => { window.location.href = '/'; }, 500);
} else { } else {
throw new Error(result.error || `登录失败: ${response.status}`); throw new Error(result.error || i18n.t('toasts.login_error_generic'));
} }
} catch (error) { } catch (error) {
toast.show(error.message, 'error'); toast.show(error.message, 'error');
loginButton.disabled = false; loginButton.disabled = false;
loginButton.querySelector('span').textContent = '登录'; loginButton.querySelector('span').textContent = i18n.t('pages.login.login_btn');
} }
} }
function init() { async function initApp() {
theme.init(); // 主题设置逻辑
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) { if (DOMElements.loginForm) {
DOMElements.loginForm.addEventListener('submit', handleLogin); DOMElements.loginForm.addEventListener('submit', handleLogin);
} }
} // 语言切换按钮事件监听 (从第一个片段引入)
init(); 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

@ -1,8 +1,9 @@
// js/notifications.js - 提供Toast和Dialog两种通知 // js/notifications.js - 提供Toast和Dialog两种通知
// 这个模块在初始化时需要知道容器的DOM元素
let toastContainer; let toastContainer;
let dialogContainer; let dialogContainer;
let modalContainer; // 虽然在此文件中不直接使用, 但 init 中保留以示完整
let t; // 模块级翻译函数变量
function hideToast(toastElement) { function hideToast(toastElement) {
if (!toastElement) return; if (!toastElement) return;
@ -11,11 +12,14 @@ function hideToast(toastElement) {
} }
export const notification = { export const notification = {
init: (toastEl, dialogEl) => { init: (toastEl, dialogEl, modalEl, translator) => {
toastContainer = toastEl; toastContainer = toastEl;
dialogContainer = dialogEl; dialogContainer = dialogEl;
modalContainer = modalEl;
t = translator; // 保存从外部传入的翻译函数
}, },
toast: (message, type = 'info', duration = 3000) => { 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 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 iconClass = icons[type] || 'fa-info-circle';
const toastElement = document.createElement('div'); const toastElement = document.createElement('div');
@ -29,28 +33,44 @@ export const notification = {
hideToast(toastElement); hideToast(toastElement);
}); });
}, },
confirm: (message) => { confirm: (message, title = '', options = {}) => {
return new Promise(resolve => { 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 = ` const dialogHTML = `
<div class="dialog-box"> <div class="dialog-box">
${title ? `<h3>${title}</h3>` : ''}
<p class="dialog-message">${message}</p> <p class="dialog-message">${message}</p>
<div class="dialog-actions"> <div class="dialog-actions">
<button class="btn btn-secondary" data-action="cancel">取消</button> <button class="btn btn-secondary" data-action="cancel">${cancelText}</button>
<button class="btn btn-primary" data-action="confirm">确定</button> <button class="btn btn-primary" data-action="confirm">${confirmText}</button>
</div> </div>
</div>`; </div>`;
dialogContainer.innerHTML = dialogHTML; dialogContainer.innerHTML = dialogHTML;
dialogContainer.classList.add('active'); dialogContainer.classList.add('active');
const eventHandler = (e) => { const eventHandler = (e) => {
const actionButton = e.target.closest('[data-action]'); const actionButton = e.target.closest('[data-action]');
if (!actionButton) return; if (!actionButton) return;
closeDialog(actionButton.dataset.action === 'confirm'); closeDialog(actionButton.dataset.action === 'confirm');
}; };
const closeDialog = (result) => { const closeDialog = (result) => {
dialogContainer.removeEventListener('click', eventHandler); dialogContainer.removeEventListener('click', eventHandler);
dialogContainer.classList.remove('active'); dialogContainer.classList.remove('active');
setTimeout(() => { dialogContainer.innerHTML = ''; resolve(result); }, 200); setTimeout(() => { dialogContainer.innerHTML = ''; resolve(result); }, 200);
}; };
dialogContainer.addEventListener('click', eventHandler); dialogContainer.addEventListener('click', eventHandler);
}); });
} }

View file

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

View file

@ -1,5 +1,13 @@
// js/ui.js - 管理所有与UI渲染和DOM操作相关的函数 // js/ui.js - 管理所有与UI渲染和DOM操作相关的函数
// 模块级私有变量, 用于存储翻译函数
let t;
// 新增: 初始化函数, 用于接收翻译函数
export function initUI(translator) {
t = translator;
}
export const DOMElements = { export const DOMElements = {
sidebar: document.getElementById('sidebar'), sidebar: document.getElementById('sidebar'),
menuToggleBtn: document.getElementById('menu-toggle-btn'), menuToggleBtn: document.getElementById('menu-toggle-btn'),
@ -45,14 +53,14 @@ export function switchView(viewToShow) {
export function renderConfigList(filenames) { export function renderConfigList(filenames) {
DOMElements.configListContainer.innerHTML = ''; DOMElements.configListContainer.innerHTML = '';
if (!filenames || filenames.length === 0) { if (!filenames || filenames.length === 0) {
DOMElements.configListContainer.innerHTML = '<p>还没有任何配置,请创建一个。</p>'; DOMElements.configListContainer.innerHTML = `<p>${t('configs.no_configs')}</p>`;
return; return;
} }
filenames.forEach(filename => { filenames.forEach(filename => {
const item = document.createElement('li'); const item = document.createElement('li');
item.className = 'config-item'; item.className = 'config-item';
item.dataset.filename = filename; 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); DOMElements.configListContainer.appendChild(item);
}); });
} }
@ -61,21 +69,21 @@ export function addKeyValueInput(container, keyName, valueName, key = '', value
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'header-entry'; div.className = 'header-entry';
div.innerHTML = ` div.innerHTML = `
<input type="text" name="${keyName}" placeholder="Key" value="${key}"> <input type="text" name="${keyName}" placeholder="${t('form.key_placeholder')}" value="${key}">
<input type="text" name="${valueName}" placeholder="Value" value="${value}"> <input type="text" name="${valueName}" placeholder="${t('form.value_placeholder')}" value="${value}">
<button type="button" class="btn-icon" onclick="this.parentElement.remove()" title="移除此条目"> <button type="button" class="btn-icon" onclick="this.parentElement.remove()" title="${t('common.remove_item')}">
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
</button>`; </button>`;
container.appendChild(div); container.appendChild(div);
} }
export function addSingleInput(container, inputName, placeholder, value = '') { export function addSingleInput(container, inputName, placeholderKey, value = '') {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'header-entry'; div.className = 'header-entry';
div.style.gridTemplateColumns = '1fr auto'; div.style.gridTemplateColumns = '1fr auto';
div.innerHTML = ` div.innerHTML = `
<input type="text" name="${inputName}" placeholder="${placeholder}" value="${value}"> <input type="text" name="${inputName}" placeholder="${t(placeholderKey)}" value="${value}">
<button type="button" class="btn-icon" onclick="this.parentElement.remove()" title="移除此上游"> <button type="button" class="btn-icon" onclick="this.parentElement.remove()" title="${t('common.remove_upstream')}">
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
</button>`; </button>`;
container.appendChild(div); container.appendChild(div);
@ -97,7 +105,7 @@ export function fillForm(config, originalFilename) {
DOMElements.multiUpstreamContainer.innerHTML = ''; DOMElements.multiUpstreamContainer.innerHTML = '';
if (upstreamConfig.muti_upstream && upstreamConfig.upstream_servers) { if (upstreamConfig.muti_upstream && upstreamConfig.upstream_servers) {
upstreamConfig.upstream_servers.forEach(server => { 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 = ''; DOMElements.upstreamHeadersContainer.innerHTML = '';
@ -137,21 +145,22 @@ export function updateCaddyStatusView(status, handlers) {
const dot = DOMElements.caddyStatusIndicator.querySelector('.status-dot'); const dot = DOMElements.caddyStatusIndicator.querySelector('.status-dot');
const text = DOMElements.caddyStatusIndicator.querySelector('.status-text'); const text = DOMElements.caddyStatusIndicator.querySelector('.status-text');
const buttonContainer = DOMElements.caddyActionButtonContainer; const buttonContainer = DOMElements.caddyActionButtonContainer;
if (!dot || !text || !buttonContainer) return;
dot.className = 'status-dot'; dot.className = 'status-dot';
buttonContainer.innerHTML = ''; buttonContainer.innerHTML = '';
let statusText, dotClass; let statusText, dotClass;
switch (status) { switch (status) {
case 'running': case 'running':
statusText = '运行中'; dotClass = 'running'; statusText = t('status.running'); dotClass = 'running';
buttonContainer.appendChild(createButton('重载配置', 'btn-warning', handleReloadCaddy)); buttonContainer.appendChild(createButton(t('caddy.reload_btn'), 'btn-warning', handleReloadCaddy));
buttonContainer.appendChild(createButton('停止 Caddy', 'btn-danger', handleStopCaddy)); buttonContainer.appendChild(createButton(t('caddy.stop_btn'), 'btn-danger', handleStopCaddy));
break; break;
case 'stopped': case 'stopped':
statusText = '已停止'; dotClass = 'stopped'; statusText = t('status.stopped'); dotClass = 'stopped';
buttonContainer.appendChild(createButton('启动 Caddy', 'btn-success', handleStartCaddy)); buttonContainer.appendChild(createButton(t('caddy.start_btn'), 'btn-success', handleStartCaddy));
break; break;
case 'checking': statusText = '检查中...'; dotClass = 'checking'; break; case 'checking': statusText = t('status.checking'); dotClass = 'checking'; break;
default: statusText = '状态未知'; dotClass = 'error'; break; default: statusText = t('status.unknown'); dotClass = 'error'; break;
} }
text.textContent = statusText; text.textContent = statusText;
dot.classList.add(dotClass); dot.classList.add(dotClass);
@ -177,24 +186,21 @@ export function updateSegmentedControl(activeButton) {
slider.style.transform = `translateX(${activeButton.offsetLeft}px)`; slider.style.transform = `translateX(${activeButton.offsetLeft}px)`;
} }
// 彻底重构: 使用 Promise 并管理自己的事件监听器生命周期
export function createPresetSelectionModal(presets) { export function createPresetSelectionModal(presets) {
return new Promise(resolve => { return new Promise(resolve => {
const modalContainer = DOMElements.modalContainer; const modalContainer = DOMElements.modalContainer;
if (!modalContainer) return resolve(null); // 安全退出 if (!modalContainer) return resolve(null);
const presetItems = presets.map(p => ` const presetItems = presets.map(p => `
<li data-preset-id="${p.id}"> <li data-preset-id="${p.id}">
<strong>${p.name}</strong> <strong>${t(p.name_key) || p.name}</strong>
<p>${p.description}</p> <p>${t(p.desc_key) || p.description}</p>
</li> </li>
`).join(''); `).join('');
const modalHTML = ` const modalHTML = `
<div class="modal-overlay"></div> <div class="modal-overlay"></div>
<div class="modal-box"> <div class="modal-box">
<header class="modal-header"> <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> <button class="btn-icon" data-modal-close><i class="fa-solid fa-xmark"></i></button>
</header> </header>
<div class="modal-content"> <div class="modal-content">
@ -202,29 +208,22 @@ export function createPresetSelectionModal(presets) {
</div> </div>
</div> </div>
`; `;
modalContainer.innerHTML = modalHTML; modalContainer.innerHTML = modalHTML;
requestAnimationFrame(() => modalContainer.classList.add('active')); requestAnimationFrame(() => modalContainer.classList.add('active'));
const cleanupAndResolve = (value) => { const cleanupAndResolve = (value) => {
modalContainer.removeEventListener('click', eventHandler); modalContainer.removeEventListener('click', eventHandler);
modalContainer.classList.remove('active'); modalContainer.classList.remove('active');
setTimeout(() => { setTimeout(() => { modalContainer.innerHTML = ''; resolve(value); }, 300);
modalContainer.innerHTML = '';
resolve(value);
}, 300);
}; };
const eventHandler = (e) => { const eventHandler = (e) => {
if (e.target.classList.contains('modal-overlay') || e.target.closest('[data-modal-close]')) { 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]'); const listItem = e.target.closest('li[data-preset-id]');
if (listItem) { if (listItem) {
cleanupAndResolve(listItem.dataset.presetId); // 用户选择 cleanupAndResolve(listItem.dataset.presetId);
} }
}; };
modalContainer.addEventListener('click', eventHandler); modalContainer.addEventListener('click', eventHandler);
}); });
} }
@ -233,28 +232,30 @@ export function createCustomSelect(containerId, options, onSelect) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container) return; if (!container) return;
// 从容器的ID动态生成隐藏input的name属性
// 例如, id 'select-log-level' -> name 'log_level'
const inputName = container.id.replace('select-', '').replace(/-/g, '_'); 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}">`; container.innerHTML = `<div class="select-selected"></div><div class="select-items"></div><input type="hidden" name="${inputName}">`;
const selectedDiv = container.querySelector('.select-selected'); const selectedDiv = container.querySelector('.select-selected');
const itemsDiv = container.querySelector('.select-items'); const itemsDiv = container.querySelector('.select-items');
const hiddenInput = container.querySelector('input[type="hidden"]'); const hiddenInput = container.querySelector('input[type="hidden"]');
itemsDiv.innerHTML = ''; itemsDiv.innerHTML = '';
if (!options || options.length === 0) { if (!options || options.length === 0) {
selectedDiv.textContent = '无可用选项'; selectedDiv.textContent = t('common.no_options');
return; return;
} }
options.forEach((option, index) => { options.forEach((option, index) => {
const item = document.createElement('div'); const item = document.createElement('div');
item.textContent = option; const optionText = typeof option === 'object' ? option.name : option;
item.dataset.value = option; const optionValue = typeof option === 'object' ? option.value : option;
item.textContent = optionText;
item.dataset.value = optionValue;
if (index === 0) { if (index === 0) {
selectedDiv.textContent = option; selectedDiv.textContent = optionText;
hiddenInput.value = option; hiddenInput.value = optionValue;
} }
item.addEventListener('click', function(e) { item.addEventListener('click', function(e) {
selectedDiv.textContent = this.textContent; selectedDiv.textContent = this.textContent;
@ -266,6 +267,7 @@ export function createCustomSelect(containerId, options, onSelect) {
}); });
itemsDiv.appendChild(item); itemsDiv.appendChild(item);
}); });
selectedDiv.addEventListener('click', (e) => { selectedDiv.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
document.querySelectorAll('.select-items.select-show').forEach(openSelect => { document.querySelectorAll('.select-items.select-show').forEach(openSelect => {
@ -277,6 +279,7 @@ export function createCustomSelect(containerId, options, onSelect) {
itemsDiv.classList.toggle('select-show'); itemsDiv.classList.toggle('select-show');
selectedDiv.classList.toggle('select-arrow-active'); selectedDiv.classList.toggle('select-arrow-active');
}); });
document.addEventListener('click', () => { document.addEventListener('click', () => {
itemsDiv.classList.remove('select-show'); itemsDiv.classList.remove('select-show');
if(selectedDiv) selectedDiv.classList.remove('select-arrow-active'); 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> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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 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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
</head> </head>
<body class="login-page-body"> <body class="login-page-body">
<div class="login-container"> <div class="login-container">
<header class="login-header"> <header class="login-header">
<i class="fa-solid fa-rocket"></i> <i class="fa-solid fa-rocket"></i>
<h1>CaddyDash</h1> <h1 data-i18n="pages.login.welcome">欢迎使用 CaddyDash</h1>
<p>请输入您的凭证以继续</p> <p data-i18n="pages.login.prompt">请输入您的凭证以继续</p>
</header> </header>
<form id="login-form"> <form id="login-form">
<div class="form-group"> <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"> <input type="text" id="username" name="username" autocomplete="username">
</div> </div>
<div class="form-group"> <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"> <input type="password" id="password" name="password" autocomplete="current-password">
</div> </div>
<button type="submit" class="btn btn-primary btn-login"> <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> <i class="fa-solid fa-arrow-right"></i>
</button> </button>
</form> </form>
</div> </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> </body>
</html> </html>

View file

@ -22,7 +22,8 @@
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<ul> <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>配置管理</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> <li><a href="/settings.html" data-nav-id="settings"><i class="fa-solid fa-gears"></i>
<span>面板设置</span></a></li> <span>面板设置</span></a></li>
</ul> </ul>
@ -48,6 +49,13 @@
</header> </header>
<div id="view-container"> <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"> <section class="card-panel">
<h3>账户安全</h3> <h3>账户安全</h3>
<form id="reset-password-form" style="margin-top: 24px;"> <form id="reset-password-form" style="margin-top: 24px;">

2
go.mod
View file

@ -4,9 +4,11 @@ go 1.24.4
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
github.com/fenthope/compress v0.0.3
github.com/fenthope/record v0.0.3 github.com/fenthope/record v0.0.3
github.com/fenthope/sessions v0.0.1 github.com/fenthope/sessions v0.0.1
github.com/infinite-iroha/touka v0.2.2 github.com/infinite-iroha/touka v0.2.2
github.com/klauspost/compress v1.18.0
golang.org/x/crypto v0.39.0 golang.org/x/crypto v0.39.0
modernc.org/sqlite v1.38.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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:RmnQ0D9a8PWtwOODawitTe4BztTnS9wYwrDbipISNq4=
github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y= github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y=
github.com/fenthope/record v0.0.3 h1:v5urgs5LAkLMlljAT/MjW8fWuRHXPnAraTem5ui7rm4= 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/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 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=

33
main.go
View file

@ -15,10 +15,12 @@ import (
"os" "os"
"time" "time"
"github.com/fenthope/compress"
"github.com/fenthope/record" "github.com/fenthope/record"
"github.com/fenthope/sessions" "github.com/fenthope/sessions"
"github.com/fenthope/sessions/cookie" "github.com/fenthope/sessions/cookie"
"github.com/infinite-iroha/touka" "github.com/infinite-iroha/touka"
"github.com/klauspost/compress/zstd"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@ -117,6 +119,37 @@ func main() {
r := touka.Default() r := touka.Default()
r.Use(record.Middleware()) 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 := cookie.NewStore(sessionKey)
store.Options(sessions.Options{ store.Options(sessions.Options{
Path: "/", Path: "/",