diff --git a/api/auth.go b/api/auth.go index 152b0b9..0bbbd06 100644 --- a/api/auth.go +++ b/api/auth.go @@ -24,6 +24,7 @@ var ( prefixMatchPaths = []string{ // 保持前缀匹配,因为数量少 "/js/", "/css/", + "/locales", } loginMatchPaths = map[string]struct{}{ "/login": {}, diff --git a/frontend/css/.style.css b/frontend/css/.style.css new file mode 100644 index 0000000..c9443ef --- /dev/null +++ b/frontend/css/.style.css @@ -0,0 +1,1094 @@ +/* --- 全局与变量定义 --- */ +:root { + --bg-color: #f8f9fa; + --surface-color: #ffffff; + --primary-color: #4f46e5; + --primary-color-hover: #4338ca; + --danger-color: #e11d48; + --danger-color-hover: #be123c; + --success-color: #22c55e; + --success-color-hover: #16a34a; + --warning-color: #f59e0b; + --warning-color-hover: #d97706; + --checking-color: #f59e0b; + --text-color: #1f2937; + --text-color-secondary: #6b7280; + --scrollbar-track-color: transparent; + --scrollbar-thumb-color: #d1d5db; + --scrollbar-thumb-hover-color: #9ca3af; + --border-color: #e5e7eb; + --border-radius-large: 12px; + --border-radius-small: 8px; + --font-family: 'Inter', sans-serif; + --sidebar-width: 260px; + --transition-speed: 0.3s; +} + +[data-theme="dark"] { + --bg-color: #111827; + --surface-color: #1f2937; + --text-color: #f9fafb; + --text-color-secondary: #9ca3af; + --border-color: #374151; + --scrollbar-thumb-color: #4b5563; + --scrollbar-thumb-hover-color: #6b7280; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + background-color: var(--bg-color); + color: var(--text-color); + line-height: 1.6; + overflow: hidden; + /* 保持body的overflow为hidden, 确保全局滚动由特定容器控制 */ + transition: background-color var(--transition-speed), color var(--transition-speed); +} + +.hidden { + display: none !important; +} + +/* --- 自定义滚动条样式 --- */ +/* 适用于 Webkit 内核浏览器 (Chrome, Safari, Edge) */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--scrollbar-track-color); +} + +::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb-color); + border-radius: 10px; + border: 2px solid transparent; + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover-color); +} + +/* 适用于 Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color); +} + +.login-page-body { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; +} + +.login-container { + width: 100%; + max-width: 400px; + padding: 40px; + background-color: var(--surface-color); + border-radius: var(--border-radius-large); + border: 1px solid var(--border-color); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05); + text-align: center; +} + +.login-header { + margin-bottom: 32px; +} + +.login-header .fa-rocket, +.login-header .fa-magic-wand-sparkles { + font-size: 2.5rem; + color: var(--primary-color); + margin-bottom: 16px; +} + +.login-header h1 { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 8px; +} + +.login-header p { + color: var(--text-color-secondary); +} + +#login-form .form-group, +#init-form .form-group { + text-align: left; + margin-bottom: 20px; +} + +.btn-login { + margin-top: 16px; + width: 100%; + justify-content: space-between; + padding-left: 24px; + padding-right: 24px; +} + +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 12px; +} + +.toast { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-small); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + width: 320px; + opacity: 0; + transform: translateX(100%); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.toast.show { + opacity: 1; + transform: translateX(0); +} + +.toast-icon { + font-size: 1.25rem; +} + +.toast-message { + flex-grow: 1; + font-size: 0.9rem; + font-weight: 500; +} + +.toast-close { + background: none; + border: none; + color: var(--text-color-secondary); + cursor: pointer; + font-size: 1.1rem; + padding: 4px; +} + +.toast.success .toast-icon { + color: var(--success-color); +} + +.toast.error .toast-icon { + color: var(--danger-color); +} + +.toast.info .toast-icon { + color: var(--primary-color); +} + +#dialog-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.3); + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s; +} + +#dialog-container.active { + opacity: 1; + visibility: visible; +} + +.dialog-box { + background-color: var(--surface-color); + border-radius: var(--border-radius-large); + padding: 24px; + width: 90%; + max-width: 400px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + text-align: center; + transform: scale(0.95); + transition: transform 0.2s ease; +} + +#dialog-container.active .dialog-box { + transform: scale(1); +} + +.dialog-message { + margin: 16px 0 24px; + font-size: 1rem; + color: var(--text-color-secondary); +} + +.dialog-actions { + display: flex; + justify-content: center; + gap: 12px; +} + +.dialog-actions .btn { + width: auto; +} + +#modal-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1500; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.4); + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s; +} + +#modal-container.active { + opacity: 1; + visibility: visible; +} + +#modal-container .modal-box { + background-color: var(--surface-color); + border-radius: var(--border-radius-large); + width: 90%; + max-width: 500px; + text-align: left; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + transform: scale(0.95) translateY(-10px); + transition: transform 0.25s ease; +} + +#modal-container.active .modal-box { + transform: scale(1) translateY(0); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h3 { + margin: 0; + font-size: 1.2rem; +} + +.modal-content { + padding: 24px; + max-height: 60vh; + overflow-y: auto; +} + +ul.preset-list { + list-style: none; + padding: 0; + margin: 0; +} + +ul.preset-list li { + padding: 12px 16px; + border-radius: var(--border-radius-small); + cursor: pointer; + transition: background-color 0.2s ease; +} + +ul.preset-list li:hover { + background-color: var(--bg-color); +} + +ul.preset-list li p { + font-size: 0.85rem; + color: var(--text-color-secondary); + margin-top: 4px; +} + +/* --- 主应用布局 --- */ +.app-container { + display: flex; + /* 默认使用flex布局 */ + height: 100vh; +} + +.main-content { + flex-grow: 1; + padding: 24px 32px; + overflow-y: auto; + /* 主内容区可独立滚动 */ +} + +#view-container { + position: relative; +} + +.view { + animation: fadeIn 0.5s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* --- 侧边栏 --- */ +.sidebar { + position: fixed; + /* 侧边栏固定定位 */ + top: 0; + left: 0; + height: 100%; + z-index: 200; + width: var(--sidebar-width); + background-color: var(--surface-color); + padding: 24px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-color); + flex-shrink: 0; + transform: translateX(-100%); + /* 默认移出屏幕 */ + transition: transform var(--transition-speed) ease, background-color var(--transition-speed), border-color var(--transition-speed); +} + +.sidebar.is-open { + transform: translateX(0); + /* 侧边栏打开时移入屏幕 */ + box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); + /* 打开时添加阴影 */ +} + +/* --- 侧边栏遮罩层样式 --- */ +.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; +} + +.sidebar-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 32px; + font-size: 1.5rem; + color: var(--primary-color); +} + +.sidebar-header h1 { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-color); +} + +.sidebar-nav { + flex-grow: 1; +} + +.sidebar-nav ul li a { + display: flex; + align-items: center; + gap: 16px; + padding: 12px; + border-radius: var(--border-radius-small); + color: var(--text-color-secondary); + font-weight: 500; + transition: all 0.2s ease; +} + +.sidebar-nav ul li a:hover { + background-color: var(--bg-color); + color: var(--text-color); +} + +.sidebar-nav ul li a.active { + background-color: var(--primary-color); + color: white; +} + +.sidebar-nav ul li a.active i { + color: white; +} + +.sidebar-bottom { + margin-top: auto; + padding-top: 16px; + border-top: 1px solid var(--border-color); + transition: border-color var(--transition-speed); +} + +.theme-switcher { + display: flex; + justify-content: space-around; + align-items: center; + padding: 12px; + background-color: var(--bg-color); + border-radius: var(--border-radius-small); + margin-bottom: 16px; + transition: background-color var(--transition-speed); +} + +.theme-switcher i { + color: var(--text-color-secondary); +} + +.switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + border-radius: 24px; + transition: var(--transition-speed); +} + +.slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + border-radius: 50%; + transition: var(--transition-speed); +} + +input:checked+.slider { + background-color: var(--primary-color); +} + +input:checked+.slider:before { + transform: translateX(20px); +} + +.caddy-control-panel { + margin-top: 16px; + padding: 12px; + background-color: var(--bg-color); + border-radius: var(--border-radius-small); + transition: background-color var(--transition-speed); +} + +#caddy-action-button-container { + display: flex; + flex-direction: column; + gap: 8px; +} + +.caddy-status { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + transition: background-color var(--transition-speed); +} + +.status-dot.running { + background-color: var(--success-color); +} + +.status-dot.stopped { + background-color: var(--danger-color); +} + +.status-dot.checking { + background-color: var(--checking-color); +} + +.status-dot.error { + background-color: var(--text-color-secondary); +} + +.status-text { + font-weight: 500; + font-size: 0.875rem; + color: var(--text-color-secondary); +} + +.logout-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-color); + transition: border-color var(--transition-speed); +} + +.main-header { + display: flex; + align-items: center; + margin-bottom: 24px; +} + +.main-header h2 { + font-size: 1.75rem; + font-weight: 700; + flex-grow: 1; +} + +#menu-toggle-btn { + display: none; + /* 默认隐藏, 仅在小屏幕显示 */ + margin-right: 16px; +} + +.card-panel { + background-color: var(--surface-color); + border-radius: var(--border-radius-large); + padding: 24px; + margin-bottom: 24px; + border: 1px solid var(--border-color); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); + transition: background-color var(--transition-speed), border-color var(--transition-speed); +} + +.form-panel-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; +} + +.form-panel-header h3 { + flex-grow: 1; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + border: 1px solid transparent; + border-radius: var(--border-radius-small); + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; +} + +.btn:active { + transform: scale(0.97); +} + +.btn:disabled { + background-color: #e5e7eb; + color: #9ca3af; + cursor: not-allowed; + border-color: transparent; + transform: none; +} + +[data-theme="dark"] .btn:disabled { + background-color: #374151; + color: #6b7280; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--primary-color-hover); +} + +.btn-secondary { + background-color: var(--surface-color); + color: var(--text-color); + border-color: var(--border-color); +} + +.btn-secondary:hover:not(:disabled) { + border-color: #ced4da; + background-color: var(--bg-color); +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background-color: var(--danger-color-hover); +} + +.btn-success { + background-color: var(--success-color); + color: white; +} + +.btn-success:hover:not(:disabled) { + background-color: var(--success-color-hover); +} + +.btn-warning { + background-color: var(--warning-color); + color: white; +} + +.btn-warning:hover:not(:disabled) { + background-color: var(--warning-color-hover); +} + +.btn-small { + padding: 6px 12px; + font-size: 0.875rem; + width: auto; +} + +.btn-icon { + background: none; + border: none; + color: var(--text-color-secondary); + cursor: pointer; + width: 40px; + height: 40px; + font-size: 1.1rem; + border-radius: 50%; + width: auto; +} + +.btn-icon:hover { + background-color: var(--bg-color); + color: var(--text-color); +} + +.btn-link { + background: none; + border: none; + color: var(--primary-color); + cursor: pointer; + font-weight: 600; + padding: 4px 8px; + width: auto; +} + +.btn-link:hover { + text-decoration: underline; +} + +.main-header .btn-primary { + width: auto; +} + +.form-actions .btn { + width: auto; +} + +.config-list-container { + display: flex; + flex-direction: column; + gap: 12px; +} + +.config-item { + display: flex; + align-items: center; + padding: 12px 16px; + background-color: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-small); + transition: all 0.2s ease; +} + +.config-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); +} + +.config-item-name { + font-weight: 500; + flex-grow: 1; +} + +.config-item-actions { + display: flex; + gap: 8px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; + margin-bottom: 24px; +} + +.form-group label, +fieldset legend { + display: block; + font-weight: 500; + margin-bottom: 8px; + color: var(--text-color-secondary); + font-size: 0.875rem; +} + +.form-group input { + width: 100%; + padding: 12px; + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-small); + color: var(--text-color); + font-size: 1rem; + transition: border-color 0.2s, box-shadow 0.2s, background-color var(--transition-speed); +} + +.form-group input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2); +} + +/* 重置数字输入框的默认样式 */ +input[type="number"] { + -moz-appearance: textfield; +} + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +fieldset { + border: none; + padding: 0; + margin: 0 0 24px 0; +} + +.sub-fieldset { + border: 1px solid var(--border-color); + border-radius: var(--border-radius-small); + padding: 16px; + margin-top: 20px; +} + +.sub-legend-group { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.sub-legend-group .sub-legend { + margin-bottom: 0; +} + +.sub-legend { + font-weight: 500; + color: var(--text-color-secondary); + font-size: 0.875rem; + margin-bottom: 12px; +} + +.checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-top: 8px; +} + +.custom-checkbox { + position: relative; + display: inline-flex; + align-items: center; + cursor: pointer; + gap: 12px; +} + +.custom-checkbox input { + display: none; +} + +.custom-checkbox .checkmark { + width: 20px; + height: 20px; + border: 2px solid var(--border-color); + border-radius: 6px; + transition: all 0.2s; +} + +.custom-checkbox input:checked+.checkmark { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.custom-checkbox .checkmark::after { + content: "\f00c"; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + font-size: 12px; + color: white; + position: absolute; + top: 3px; + left: 3px; + transform: scale(0); + transition: transform 0.2s; +} + +.custom-checkbox input:checked+.checkmark::after { + transform: scale(1); +} + +.header-entry { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 12px; + margin-bottom: 12px; + align-items: center; +} + +.header-entry input { + width: 100%; + padding: 10px; + background-color: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-small); + color: var(--text-color); +} + +.segmented-control { + position: relative; + display: flex; + width: 100%; + background-color: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-small); + padding: 4px; + transition: background-color var(--transition-speed), border-color var(--transition-speed); +} + +.segmented-control button { + flex: 1; + padding: 8px 12px; + border: none; + background-color: transparent; + color: var(--text-color-secondary); + font-weight: 600; + cursor: pointer; + transition: color 0.2s ease; + z-index: 2; +} + +.segmented-control button:hover { + color: var(--text-color); +} + +.segmented-control button.active { + color: white; +} + +[data-theme="dark"] .segmented-control button.active { + color: var(--text-color); +} + +#segmented-control-slider { + position: absolute; + top: 4px; + bottom: 4px; + background-color: var(--primary-color); + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), width 0.25s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1; +} + +.custom-select { + position: relative; +} + +.select-selected { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 12px; + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-small); + color: var(--text-color); + font-size: 1rem; + font-family: inherit; + cursor: pointer; + user-select: none; + transition: border-color 0.2s, box-shadow 0.2s, background-color var(--transition-speed); + line-height: 1.5; + height: calc(1.5em + 24px + 2px); +} + +.select-selected.select-arrow-active { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2); +} + +.select-selected::after { + content: '\f078'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + transition: transform var(--transition-speed) ease; +} + +.select-selected.select-arrow-active::after { + transform: rotate(180deg); +} + +.select-items { + position: absolute; + background-color: var(--surface-color); + top: 100%; + left: 0; + right: 0; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-small); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + z-index: 99; + margin-top: 8px; + max-height: 200px; + overflow-y: auto; + opacity: 0; + transform: translateY(-10px); + visibility: hidden; + transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s; +} + +.select-items.select-show { + opacity: 1; + transform: translateY(0); + visibility: visible; +} + +.select-items div { + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s; +} + +.select-items div:hover, +.same-as-selected { + background-color: var(--bg-color); +} + +#rendered-output-panel pre { + background-color: var(--bg-color); + padding: 16px; + border-radius: var(--border-radius-small); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + border: 1px solid var(--border-color); + transition: background-color var(--transition-speed); +} + +/* --- 响应式设计 --- */ +@media (max-width: 992px) { + .app-container { + /* 在小屏幕上让 main-content 独立滚动, 而不是整个容器 */ + display: block; + overflow-y: hidden; + /* 隐藏容器本身的Y轴滚动条 */ + } + + .main-content { + /* 确保主内容区填满视口高度, 并处理其自身的滚动 */ + height: 100vh; + width: 100%; + margin-left: 0; + padding: 16px; + /* 调整内边距适应小屏幕 */ + } + + #menu-toggle-btn { + display: inline-flex; + /* 在小屏幕上显示菜单切换按钮 */ + } + + .main-header .btn-text { + display: none; + /* 在小屏幕上隐藏按钮文本 */ + } + + /* 侧边栏的定位和显示/隐藏逻辑已在全局 .sidebar 和 .sidebar.is-open 中处理, 无需在此重复 */ + /* .sidebar { + // 这些属性已在全局定义或由 .sidebar.is-open 处理 + position: fixed; + height: 100%; + z-index: 200; + transform: translateX(-100%); + box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); + } + .sidebar.is-open { + transform: translateX(0); + } */ + + /* 确保侧边栏内的文本在小屏幕上可见, 不被隐藏 */ + .sidebar-nav span, + .logout-section span { + display: inline; + } + + .caddy-control-panel .btn span { + display: inline; + } +} \ No newline at end of file diff --git a/frontend/css/style.css b/frontend/css/style.css index c330e81..9264bb7 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -46,6 +46,7 @@ body { color: var(--text-color); line-height: 1.6; overflow: hidden; + /* 防止主页面滚动条出现, 内部组件自行管理滚动 */ transition: background-color var(--transition-speed), color var(--transition-speed); } @@ -53,8 +54,7 @@ body { display: none !important; } -/* --- 新增: 自定义滚动条样式 --- */ -/* 适用于 Webkit 内核浏览器 (Chrome, Safari, Edge) */ +/* --- 自定义滚动条样式 (适用于 Webkit 内核浏览器) --- */ ::-webkit-scrollbar { width: 8px; height: 8px; @@ -69,18 +69,21 @@ body { border-radius: 10px; border: 2px solid transparent; background-clip: content-box; + /* 使边框内缩 */ } ::-webkit-scrollbar-thumb:hover { background-color: var(--scrollbar-thumb-hover-color); } -/* 适用于 Firefox */ +/* --- 自定义滚动条样式 (适用于 Firefox) --- */ * { scrollbar-width: thin; + /* 使滚动条更窄 */ scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color); } +/* --- 登录页样式 --- */ .login-page-body { display: flex; align-items: center; @@ -130,10 +133,12 @@ body { margin-top: 16px; width: 100%; justify-content: space-between; + /* 内部元素左右对齐 */ padding-left: 24px; padding-right: 24px; } +/* --- Toast 提示样式 --- */ .toast-container { position: fixed; top: 20px; @@ -156,12 +161,14 @@ body { width: 320px; opacity: 0; transform: translateX(100%); + /* 初始位置在屏幕外 */ transition: opacity 0.3s ease, transform 0.3s ease; } .toast.show { opacity: 1; transform: translateX(0); + /* 显示时滑入 */ } .toast-icon { @@ -195,6 +202,7 @@ body { color: var(--primary-color); } +/* --- Dialog 确认框样式 --- */ #dialog-container { position: fixed; top: 0; @@ -208,6 +216,7 @@ body { background-color: rgba(0, 0, 0, 0.3); opacity: 0; visibility: hidden; + /* 默认隐藏 */ transition: opacity 0.2s ease, visibility 0.2s; } @@ -225,11 +234,13 @@ body { box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); text-align: center; transform: scale(0.95); + /* 初始略微缩小 */ transition: transform 0.2s ease; } #dialog-container.active .dialog-box { transform: scale(1); + /* 显示时放大 */ } .dialog-message { @@ -246,8 +257,10 @@ body { .dialog-actions .btn { width: auto; + /* 按钮宽度自适应 */ } +/* --- Modal 模态框样式 --- */ #modal-container { position: fixed; top: 0; @@ -261,6 +274,7 @@ body { background-color: rgba(0, 0, 0, 0.4); opacity: 0; visibility: hidden; + /* 默认隐藏 */ transition: opacity 0.2s ease, visibility 0.2s; } @@ -277,11 +291,13 @@ body { text-align: left; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); transform: scale(0.95) translateY(-10px); + /* 初始略微缩小并上移 */ transition: transform 0.25s ease; } #modal-container.active .modal-box { transform: scale(1) translateY(0); + /* 显示时放大并恢复位置 */ } .modal-header { @@ -300,7 +316,9 @@ body { .modal-content { padding: 24px; max-height: 60vh; + /* 限制内容高度, 避免模态框过高 */ overflow-y: auto; + /* 内容超出时显示滚动条 */ } ul.preset-list { @@ -326,6 +344,7 @@ ul.preset-list li p { margin-top: 4px; } +/* --- 主应用布局 --- */ .app-container { display: flex; height: 100vh; @@ -333,16 +352,20 @@ ul.preset-list li p { .main-content { flex-grow: 1; + /* 占据剩余空间 */ padding: 24px 32px; overflow-y: auto; + /* 允许内容垂直滚动 */ } +/* --- 视图切换动画 --- */ #view-container { position: relative; } .view { animation: fadeIn 0.5s ease; + /* 视图切换时的淡入动画 */ } @keyframes fadeIn { @@ -357,6 +380,7 @@ ul.preset-list li p { } } +/* --- 侧边栏 --- */ .sidebar { width: var(--sidebar-width); background-color: var(--surface-color); @@ -365,6 +389,7 @@ ul.preset-list li p { flex-direction: column; border-right: 1px solid var(--border-color); flex-shrink: 0; + /* 防止侧边栏被挤压 */ transition: transform var(--transition-speed) ease, background-color var(--transition-speed), border-color var(--transition-speed); } @@ -385,6 +410,7 @@ ul.preset-list li p { .sidebar-nav { flex-grow: 1; + /* 占据剩余空间 */ } .sidebar-nav ul li a { @@ -414,6 +440,7 @@ ul.preset-list li p { .sidebar-bottom { margin-top: auto; + /* 推到底部 */ padding-top: 16px; border-top: 1px solid var(--border-color); transition: border-color var(--transition-speed); @@ -536,6 +563,28 @@ input:checked+.slider:before { transition: border-color var(--transition-speed); } +/* 侧边栏遮罩层 (仅用于移动端) */ +.sidebar-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 199; + /* 位于侧边栏下方 */ + opacity: 0; + visibility: hidden; + /* 默认隐藏 */ + transition: opacity 0.3s ease, visibility 0.3s; +} + +.sidebar-overlay.is-visible { + opacity: 1; + visibility: visible; +} + +/* --- 主内容区头部样式 --- */ .main-header { display: flex; align-items: center; @@ -550,9 +599,11 @@ input:checked+.slider:before { #menu-toggle-btn { display: none; + /* 默认隐藏, 仅在小屏幕显示 */ margin-right: 16px; } +/* --- 卡片面板样式 --- */ .card-panel { background-color: var(--surface-color); border-radius: var(--border-radius-large); @@ -574,6 +625,7 @@ input:checked+.slider:before { flex-grow: 1; } +/* --- 按钮样式 --- */ .btn { display: inline-flex; align-items: center; @@ -586,10 +638,12 @@ input:checked+.slider:before { cursor: pointer; transition: all 0.2s ease; width: 100%; + /* 默认宽度为100% */ } .btn:active { transform: scale(0.97); + /* 点击时轻微缩小 */ } .btn:disabled { @@ -598,6 +652,7 @@ input:checked+.slider:before { cursor: not-allowed; border-color: transparent; transform: none; + /* 禁用时无动画 */ } [data-theme="dark"] .btn:disabled { @@ -656,6 +711,7 @@ input:checked+.slider:before { padding: 6px 12px; font-size: 0.875rem; width: auto; + /* 小按钮宽度自适应 */ } .btn-icon { @@ -668,6 +724,7 @@ input:checked+.slider:before { font-size: 1.1rem; border-radius: 50%; width: auto; + /* 图标按钮宽度自适应 */ } .btn-icon:hover { @@ -683,6 +740,7 @@ input:checked+.slider:before { font-weight: 600; padding: 4px 8px; width: auto; + /* 链接按钮宽度自适应 */ } .btn-link:hover { @@ -691,12 +749,15 @@ input:checked+.slider:before { .main-header .btn-primary { width: auto; + /* 主头部按钮宽度自适应 */ } .form-actions .btn { width: auto; + /* 表单操作按钮宽度自适应 */ } +/* --- 配置列表样式 --- */ .config-list-container { display: flex; flex-direction: column; @@ -715,6 +776,7 @@ input:checked+.slider:before { .config-item:hover { transform: translateY(-2px); + /* 悬停时轻微上浮 */ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); } @@ -728,9 +790,11 @@ input:checked+.slider:before { gap: 8px; } +/* --- 表单样式 --- */ .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + /* 响应式网格布局 */ gap: 24px; margin-bottom: 24px; } @@ -761,14 +825,16 @@ fieldset legend { box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2); } -/* 新增: 重置数字输入框的默认样式 */ +/* 重置数字输入框的默认样式 (移除上下箭头) */ input[type="number"] { -moz-appearance: textfield; + /* Firefox */ } input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; + /* Chrome, Safari */ margin: 0; } @@ -820,6 +886,7 @@ fieldset { .custom-checkbox input { display: none; + /* 隐藏原生复选框 */ } .custom-checkbox .checkmark { @@ -837,6 +904,7 @@ fieldset { .custom-checkbox .checkmark::after { content: "\f00c"; + /* Font Awesome 对勾图标 */ font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 12px; @@ -845,20 +913,23 @@ fieldset { top: 3px; left: 3px; transform: scale(0); + /* 默认隐藏 */ transition: transform 0.2s; } .custom-checkbox input:checked+.checkmark::after { transform: scale(1); + /* 选中时显示 */ } .header-entry { display: grid; grid-template-columns: 1fr 1fr auto; + /* 键、值、删除按钮 */ gap: 12px; margin-bottom: 12px; align-items: center; - /* 修改: 确保所有元素垂直居中对齐 */ + /* 确保所有元素垂直居中对齐 */ } .header-entry input { @@ -870,6 +941,7 @@ fieldset { color: var(--text-color); } +/* --- 分段控制器样式 --- */ .segmented-control { position: relative; display: flex; @@ -883,6 +955,7 @@ fieldset { .segmented-control button { flex: 1; + /* 均分空间 */ padding: 8px 12px; border: none; background-color: transparent; @@ -891,6 +964,7 @@ fieldset { cursor: pointer; transition: color 0.2s ease; z-index: 2; + /* 确保按钮在滑块之上 */ } .segmented-control button:hover { @@ -903,6 +977,7 @@ fieldset { [data-theme="dark"] .segmented-control button.active { color: var(--text-color); + /* 深色模式下保持文本颜色 */ } #segmented-control-slider { @@ -913,14 +988,16 @@ fieldset { border-radius: 6px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), width 0.25s cubic-bezier(0.4, 0, 0.2, 1); + /* 平滑过渡 */ z-index: 1; } +/* --- 自定义选择框样式 --- */ .custom-select { position: relative; } -/* 修改: 统一 .select-selected 的外观, 使其与 input 一致 */ +/* 统一 .select-selected 的外观, 使其与 input 文本框一致 */ .select-selected { display: flex; justify-content: space-between; @@ -938,8 +1015,8 @@ fieldset { transition: border-color 0.2s, box-shadow 0.2s, background-color var(--transition-speed); line-height: 1.5; /* 与input的字体和内边距计算出的行高一致 */ - height: calc(1.5em + 24px + 2px); - /* font-size * line-height + padding * 2 + border * 2 */ + /* 准确计算高度: font-size * line-height + padding-top + padding-bottom + border-top + border-bottom */ + height: calc(1em * 1.5 + 12px * 2 + 1px * 2); } .select-selected.select-arrow-active { @@ -949,6 +1026,7 @@ fieldset { .select-selected::after { content: '\f078'; + /* Font Awesome 向下箭头图标 */ font-family: 'Font Awesome 6 Free'; font-weight: 900; transition: transform var(--transition-speed) ease; @@ -956,12 +1034,14 @@ fieldset { .select-selected.select-arrow-active::after { transform: rotate(180deg); + /* 展开时箭头旋转 */ } .select-items { position: absolute; background-color: var(--surface-color); top: 100%; + /* 位于选择框下方 */ left: 0; right: 0; border: 1px solid var(--border-color); @@ -971,9 +1051,12 @@ fieldset { margin-top: 8px; max-height: 200px; overflow-y: auto; + /* 选项过多时滚动 */ opacity: 0; transform: translateY(-10px); + /* 默认略微上移 */ visibility: hidden; + /* 默认隐藏 */ transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s; } @@ -994,48 +1077,102 @@ fieldset { background-color: var(--bg-color); } +/* --- 渲染输出面板样式 --- */ #rendered-output-panel pre { background-color: var(--bg-color); padding: 16px; border-radius: var(--border-radius-small); overflow-x: auto; + /* 允许代码水平滚动 */ white-space: pre-wrap; + /* 自动换行 */ word-break: break-all; + /* 单词内断行 */ border: 1px solid var(--border-color); transition: background-color var(--transition-speed); } +/* --- 角落语言切换器样式 --- */ +.language-switcher-corner { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 100; +} +.language-switcher-corner .btn-icon { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + width: 48px; + height: 48px; + font-size: 1.25rem; +} +.language-switcher-corner .btn-icon:hover { + background-color: var(--bg-color); + border-color: var(--primary-color); +} +.language-switcher-corner ul { + position: absolute; + bottom: 60px; /* 在按钮上方 */ + right: 0; + list-style: none; + padding: 8px; + margin: 0; + background-color: var(--surface-color); + border-radius: var(--border-radius-small); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + border: 1px solid var(--border-color); + width: 150px; +} +.language-switcher-corner ul li { + padding: 8px 12px; + cursor: pointer; + border-radius: 6px; + font-weight: 500; +} +.language-switcher-corner ul li:hover { + background-color: var(--bg-color); +} +.language-switcher-corner ul li.active { + background-color: var(--primary-color); + color: white; +} +/* --- 响应式设计 (媒体查询) --- */ @media (max-width: 992px) { .sidebar { position: fixed; + /* 在小屏幕上脱离文档流 */ z-index: 200; + /* 确保在最上层 */ height: 100%; transform: translateX(-100%); + /* 默认隐藏在左侧 */ box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); + /* 添加阴影效果 */ } .sidebar.is-open { transform: translateX(0); + /* 打开时滑入 */ } #menu-toggle-btn { display: inline-flex; + /* 仅在小屏幕上显示菜单切换按钮 */ } .main-header .btn-text { display: none; + /* 在小屏幕上隐藏按钮内的长文本 */ } - .sidebar-nav span, - .logout-section span { - display: inline; - } - - .caddy-control-panel .btn span { - display: inline; + /* 隐藏侧边栏导航和底部区域的文本, 只显示图标 */ + .main-header .btn-primary .btn-text { + display: none; } .main-content { padding: 16px; + /* 调整小屏幕内边距 */ } } \ No newline at end of file diff --git a/frontend/global.html b/frontend/global.html index 222ba36..08a875d 100644 --- a/frontend/global.html +++ b/frontend/global.html @@ -4,7 +4,7 @@ - 全局配置 - CaddyDash + 全局配置 - CaddyDash @@ -14,18 +14,18 @@
-
+ class="fa-solid fa-right-from-bracket">退出登录
-

全局 Caddyfile 配置

+

全局 Caddyfile 配置

-

修改这些配置将会重写您的主 Caddyfile 并触发 - Caddy 重载。

+

修改这些配置将会重写您的主 Caddyfile 并触发 Caddy 重载。

- 通用选项 + 通用选项
-
-
-
+ class="checkmark"> 启用Debug模式 + class="checkmark"> 启用Prometheus指标
- 主日志配置 + 主日志配置
-
+
-
-
-
+
+
+ placeholder="例如: 24h" data-i18n-placeholder="form.log_rotate_keep_for_time_placeholder">
- 全局TLS配置 (ACME) + 全局TLS配置 (ACME) + name="enable_dns_challenge"> 启用全局 DNS + Challenge
-
- 加密客户端问候 (ECH) + 加密客户端问候 (ECH) + name="enable_ech"> 启用 ECH (实验性功能)
+ 保存并重载
@@ -133,6 +135,7 @@
+ diff --git a/frontend/index.html b/frontend/index.html index 003966a..63e4d33 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,15 +1,17 @@ - + + - CaddyDash + CaddyDash +
-

配置管理

- +

站点配置管理

+
@@ -47,114 +58,134 @@
- +
-
- 全局请求头 (Headers) +
+ 全局请求头 (Headers)
- 附加功能 + 附加功能
- - - + + +
- - + +
- +
- + + \ No newline at end of file diff --git a/frontend/init.html b/frontend/init.html index 1c2d259..1b8bf78 100644 --- a/frontend/init.html +++ b/frontend/init.html @@ -1,56 +1,52 @@ - - 首次设置 - CaddyDash + 首次设置 - CaddyDash - - -
-

欢迎使用 CaddyDash

-

请创建您的管理员账户以完成首次设置

+

欢迎使用 CaddyDash

+

请创建您的管理员账户以完成首次设置

- - + +
- - + +
- - + +
+ +
+ + +
- - \ No newline at end of file diff --git a/frontend/js/app.js b/frontend/js/app.js index cf5aa19..f3c87dc 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -2,13 +2,12 @@ import { state } from './state.js'; import { api } from './api.js'; -import { theme, activateNav } from './common.js'; +import { initializePage } from './common.js'; +import { t } from './locale.js'; import { notification } from './notifications.js'; -import { initCaddyStatus } from './caddy.js'; import { DOMElements, switchView, renderConfigList, addKeyValueInput, addSingleInput, fillForm, showRenderedConfig, updateCaddyStatusView, updateSegmentedControl, updateServiceModeView, updateMultiUpstreamView, createPresetSelectionModal } from './ui.js'; -const POLLING_INTERVAL = 5000; -let caddyStatusInterval; +// --- 事件处理与逻辑流 (所有 handle* 函数保持不变) --- function getFormStateAsString() { const formData = new FormData(DOMElements.configForm); @@ -27,14 +26,18 @@ function getFormStateAsString() { } async function attemptExitForm() { - if (getFormStateAsString() !== state.initialFormState) { - if (await notification.confirm('您有未保存的更改。确定要放弃吗?')) switchView(DOMElements.configListPanel); - } else switchView(DOMElements.configListPanel); + if (await getFormStateAsString() !== state.initialFormState) { + if (await notification.confirm(t('dialogs.unsaved_changes_msg'), t('dialogs.unsaved_changes_title'))) { + switchView(DOMElements.configListPanel); + } + } else { + switchView(DOMElements.configListPanel); + } } async function handleLogout() { - if (!await notification.confirm('您确定要退出登录吗?')) return; - notification.toast('正在退出...', 'info'); + if (!await notification.confirm(t('dialogs.logout_msg'))) return; + notification.toast(t('toasts.logout_processing'), 'info'); setTimeout(() => { window.location.href = `/v0/api/auth/logout`; }, 500); } @@ -42,7 +45,7 @@ async function loadAllConfigs() { try { const filenames = await api.get('/config/filenames'); renderConfigList(filenames); - } catch (error) { if (error.message) notification.toast(`加载配置列表失败: ${error.message}`, 'error'); } + } catch (error) { if (error.message) notification.toast(t('toasts.load_configs_error', { error: error.message }), 'error'); } } async function handleEditConfig(originalFilename) { @@ -53,22 +56,22 @@ async function handleEditConfig(originalFilename) { ]); state.isEditing = true; switchView(DOMElements.configFormPanel); - DOMElements.formTitle.textContent = '编辑配置'; + DOMElements.formTitle.textContent = t('pages.configs.form_title_edit'); fillForm(config, originalFilename); showRenderedConfig(rendered, originalFilename); const mode = config.upstream_config?.enable_upstream ? 'reverse_proxy' : (config.file_server_config?.enable_file_server ? 'file_server' : 'none'); updateServiceModeView(mode); - state.initialFormState = getFormStateAsString(); - } catch (error) { notification.toast(`加载配置详情失败: ${error.message}`, 'error'); } + state.initialFormState = await getFormStateAsString(); + } catch (error) { notification.toast(t('toasts.load_config_detail_error', { error: error.message }), 'error'); } } async function handleDeleteConfig(filename) { - if (!await notification.confirm(`确定要删除配置 "${filename}" 吗?`)) return; + if (!await notification.confirm(t('dialogs.delete_config_msg', { filename }), t('dialogs.delete_config_title'))) return; try { await api.delete(`/config/file/${filename}`); - notification.toast('配置已成功删除。', 'success'); + notification.toast(t('toasts.delete_success'), 'success'); loadAllConfigs(); - } catch (error) { notification.toast(`删除失败: ${error.message}`, 'error'); } + } catch (error) { notification.toast(t('toasts.delete_error', { error: error.message }), 'error'); } } async function handleSaveConfig(e) { @@ -76,7 +79,7 @@ async function handleSaveConfig(e) { const formData = new FormData(DOMElements.configForm); const domain = formData.get('domain'); if (!domain) { - notification.toast('域名不能为空。', 'error'); + notification.toast(t('toasts.error_domain_empty'), 'error'); return; } const getHeadersMap = (keyName, valueName) => { @@ -116,23 +119,23 @@ async function handleSaveConfig(e) { try { const result = await api.put(`/config/file/${domain}`, configData); state.isEditing = false; - notification.toast(result.message || '配置已成功保存。', 'success'); + notification.toast(result.message || t('toasts.save_success'), 'success'); setTimeout(() => { switchView(DOMElements.configListPanel); loadAllConfigs(); }, 500); - } catch (error) { notification.toast(`保存失败: ${error.message}`, 'error'); } + } catch (error) { notification.toast(t('toasts.save_error', { error: error.message }), 'error'); } } async function openPresetModal(targetType) { const applicablePresets = state.headerPresets.filter(p => p.target === targetType || p.target === 'any'); if (applicablePresets.length === 0) { - notification.toast(`没有适用于此区域的预设。`, 'info'); + notification.toast(t('toasts.no_presets_available'), 'info'); return; } - const presetId = await createPresetSelectionModal(applicablePresets); - if (!presetId) return; // 用户取消了选择 + const presetId = await createPresetSelectionModal(applicablePresets, t); + if (!presetId) return; let targetContainer, keyName, valueName; if (targetType === 'global') { @@ -147,18 +150,17 @@ async function openPresetModal(targetType) { return; } - // 在获取到presetId后,再执行填充逻辑 try { - notification.toast('正在加载预设...', 'info', 1000); + notification.toast(t('toasts.loading_preset'), 'info', 1000); const preset = await api.get(`/config/headers-presets/${presetId}`); if (!preset.headers) { - notification.toast('此预设无数据。', 'info'); + notification.toast(t('toasts.preset_no_data'), 'info'); return; } - const choice = await notification.confirm('如何填充预设?', '选择填充方式', { confirmText: '追加', cancelText: '替换' }); + const choice = await notification.confirm(t('dialogs.preset_fill_msg'), t('dialogs.preset_fill_title'), { confirmText: t('common.append'), cancelText: t('common.replace') }); - if (choice === false) { // 用户选择了“替换” + if (choice === false) { targetContainer.innerHTML = ''; } @@ -167,19 +169,16 @@ async function openPresetModal(targetType) { addKeyValueInput(targetContainer, keyName, valueName, key, value); }); }); - notification.toast(`已成功填充预设 "${preset.name}"`, 'success'); + notification.toast(t('toasts.preset_fill_success', { presetName: preset.name }), 'success'); } catch (error) { - notification.toast(`加载预设失败: ${error.message}`, 'error'); + notification.toast(t('toasts.load_preset_error', { error: error.message }), 'error'); } } -function init() { - theme.init(DOMElements.themeToggleInput); - notification.init(DOMElements.toastContainer, DOMElements.dialogContainer, DOMElements.modalContainer); - activateNav('configs'); - initCaddyStatus(); - +// --- 初始化与事件绑定 --- +function pageInit() { + // 这个函数包含所有特定于 app.js 的初始化逻辑 loadAllConfigs(); api.get('/config/headers-presets') @@ -187,16 +186,13 @@ function init() { state.headerPresets = presets || []; }) .catch(err => { - if (err.message) notification.toast(`加载Header预设失败: ${err.message}`, 'error'); + if (err.message) notification.toast(t('toasts.load_presets_error', { error: err.message }), 'error'); }); - DOMElements.menuToggleBtn.addEventListener('click', () => DOMElements.sidebar.classList.toggle('is-open')); - DOMElements.mainContent.addEventListener('click', () => DOMElements.sidebar.classList.remove('is-open')); - - DOMElements.addNewConfigBtn.addEventListener('click', () => { + DOMElements.addNewConfigBtn.addEventListener('click', async () => { state.isEditing = false; switchView(DOMElements.configFormPanel); - DOMElements.formTitle.textContent = '创建新配置'; + DOMElements.formTitle.textContent = t('pages.configs.form_title_create'); DOMElements.configForm.reset(); const noneButton = DOMElements.serviceModeControl.querySelector('[data-mode="none"]'); @@ -204,7 +200,7 @@ function init() { updateServiceModeView('none'); updateMultiUpstreamView(false); - state.initialFormState = getFormStateAsString(); + state.initialFormState = await getFormStateAsString(); DOMElements.headersContainer.innerHTML = ''; DOMElements.upstreamHeadersContainer.innerHTML = ''; DOMElements.multiUpstreamContainer.innerHTML = ''; @@ -247,7 +243,7 @@ function init() { DOMElements.addMultiUpstreamBtn.addEventListener('click', (e) => { e.preventDefault(); - addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', '例如: 127.0.0.1:8081'); + addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', t('form.upstream_server_placeholder')); }); DOMElements.serviceModeControl.addEventListener('click', (e) => { @@ -265,4 +261,5 @@ function init() { }); } -init(); \ No newline at end of file +// 使用通用初始化函数启动页面 +initializePage({ pageId: 'configs', pageInit: pageInit }); \ No newline at end of file diff --git a/frontend/js/caddy.js b/frontend/js/caddy.js index dd0bddd..89a868a 100644 --- a/frontend/js/caddy.js +++ b/frontend/js/caddy.js @@ -6,6 +6,9 @@ import { notification } from './notifications.js'; let caddyStatusInterval; const POLLING_INTERVAL = 5000; +// 将 t 函数保存在模块作用域内 +let translate; + const DOMElements = { caddyStatusIndicator: document.getElementById('caddy-status-indicator'), caddyActionButtonContainer: document.getElementById('caddy-action-button-container'), @@ -24,23 +27,31 @@ function updateCaddyStatusView(status) { const text = DOMElements.caddyStatusIndicator.querySelector('.status-text'); const buttonContainer = DOMElements.caddyActionButtonContainer; - if(!dot || !text || !buttonContainer) return; // 如果元素不存在,则不执行 + if(!dot || !text || !buttonContainer) return; dot.className = 'status-dot'; buttonContainer.innerHTML = ''; let statusText, dotClass; switch (status) { case 'running': - statusText = '运行中'; dotClass = 'running'; - buttonContainer.appendChild(createButton('重载配置', 'btn-warning', handleReloadCaddy)); - buttonContainer.appendChild(createButton('停止 Caddy', 'btn-danger', handleStopCaddy)); + statusText = translate('status.running'); + dotClass = 'running'; + buttonContainer.appendChild(createButton(translate('caddy.reload_btn'), 'btn-warning', handleReloadCaddy)); + buttonContainer.appendChild(createButton(translate('caddy.stop_btn'), 'btn-danger', handleStopCaddy)); break; case 'stopped': - statusText = '已停止'; dotClass = 'stopped'; - buttonContainer.appendChild(createButton('启动 Caddy', 'btn-success', handleStartCaddy)); + statusText = translate('status.stopped'); + dotClass = 'stopped'; + buttonContainer.appendChild(createButton(translate('caddy.start_btn'), 'btn-success', handleStartCaddy)); + break; + case 'checking': + statusText = translate('status.checking'); + dotClass = 'checking'; + break; + default: + statusText = translate('status.unknown'); + dotClass = 'error'; break; - case 'checking': statusText = '检查中...'; dotClass = 'checking'; break; - default: statusText = '状态未知'; dotClass = 'error'; break; } text.textContent = statusText; dot.classList.add(dotClass); @@ -59,35 +70,38 @@ async function checkCaddyStatus() { async function handleStartCaddy() { try { const result = await api.post('/caddy/run'); - notification.toast(result.message || '启动命令已发送。', 'success'); + notification.toast(result.message || translate('toasts.start_cmd_sent'), 'success'); setTimeout(checkCaddyStatus, 500); - } catch (error) { notification.toast(`启动失败: ${error.message}`, 'error'); } + } catch (error) { notification.toast(translate('toasts.start_error', { error: error.message }), 'error'); } } async function handleStopCaddy() { - if (!await notification.confirm('您确定要停止 Caddy 实例吗?')) return; + if (!await notification.confirm(translate('dialogs.stop_caddy_msg'))) return; try { const result = await api.post('/caddy/stop'); - notification.toast(result.message || '停止命令已发送。', 'info'); + notification.toast(result.message || translate('toasts.stop_cmd_sent'), 'info'); setTimeout(checkCaddyStatus, 500); - } catch(error) { notification.toast(`操作失败: ${error.message}`, 'error'); } + } catch(error) { notification.toast(translate('toasts.action_error', { error: error.message }), 'error'); } } async function handleReloadCaddy() { - if (!await notification.confirm('确定要重载 Caddy 配置吗?')) return; + if (!await notification.confirm(translate('dialogs.reload_caddy_msg'))) return; try { const result = await api.post('/caddy/restart'); - notification.toast(result.message || '重载命令已发送。', 'success'); + notification.toast(result.message || translate('toasts.reload_sent'), 'success'); setTimeout(checkCaddyStatus, 500); - } catch(error) { notification.toast(`重载失败: ${error.message}`, 'error'); } + } catch(error) { notification.toast(translate('toasts.reload_error', { error: error.message }), 'error'); } } -export function initCaddyStatus() { - // 确保通知模块已经初始化 +// initCaddyStatus 现在接收 t 函数作为参数 +export function initCaddyStatus(translator) { + // 保存翻译函数以供模块内其他函数使用 + translate = translator; + const dialogContainer = document.getElementById('dialog-container'); const toastContainer = document.getElementById('toast-container'); if (dialogContainer && toastContainer) { - notification.init(toastContainer, dialogContainer); + notification.init(toastContainer, dialogContainer, null, translate); // 将 t 函数传递给通知模块 } checkCaddyStatus(); diff --git a/frontend/js/common.js b/frontend/js/common.js index ed75a40..357e5ec 100644 --- a/frontend/js/common.js +++ b/frontend/js/common.js @@ -1,5 +1,10 @@ // js/common.js - 存放共享模块 +import { initI18n, t } from './locale.js'; +import { notification } from './notifications.js'; +import { initCaddyStatus } from './caddy.js'; +import { initUI } from './ui.js'; + const theme = { init: (toggleElement) => { const storedTheme = localStorage.getItem('theme'); @@ -55,5 +60,71 @@ function activateNav(pageId) { }); } +/** + * 初始化移动端侧边栏的开关逻辑 + */ +function initSidebar() { + const sidebar = document.getElementById('sidebar'); + const menuToggleBtn = document.getElementById('menu-toggle-btn'); + + // 动态创建并管理遮罩层 + let overlay = document.querySelector('.sidebar-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.className = 'sidebar-overlay'; + document.body.appendChild(overlay); + } + + const openSidebar = () => { + if (!sidebar || !overlay) return; + sidebar.classList.add('is-open'); + overlay.classList.add('is-visible'); + }; + + const closeSidebar = () => { + if (!sidebar || !overlay) return; + sidebar.classList.remove('is-open'); + overlay.classList.remove('is-visible'); + }; + + if (menuToggleBtn) { + menuToggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + openSidebar(); + }); + } + + overlay.addEventListener('click', closeSidebar); +} + +/** + * 通用的页面初始化函数 + * @param {object} options - 初始化选项 + * @param {string} options.pageId - 当前页面的ID, 用于导航高亮 + * @param {function} [options.pageInit=null] - 特定于该页面的额外初始化逻辑 + */ +export async function initializePage(options) { + // 1. 初始化国际化 (最高优先级) + await initI18n(); + + initUI(t); + + // 2. 初始化UI模块和通用功能 + theme.init(document.getElementById('theme-toggle-input')); + notification.init( + document.getElementById('toast-container'), + document.getElementById('dialog-container'), + document.getElementById('modal-container') + ); + activateNav(options.pageId); + initSidebar(); + initCaddyStatus(t); + + // 3. 如果有特定页面的初始化逻辑, 则执行它 + if (options.pageInit && typeof options.pageInit === 'function') { + options.pageInit(); + } +} + // 导出模块 export { theme, toast, activateNav }; \ No newline at end of file diff --git a/frontend/js/global.js b/frontend/js/global.js index 4fabd2a..2f7613b 100644 --- a/frontend/js/global.js +++ b/frontend/js/global.js @@ -1,25 +1,21 @@ // js/global.js - 全局配置页面的逻辑 -import { theme, activateNav } from './common.js'; +import { initializePage } from './common.js'; // 导入通用初始化函数 import { api } from './api.js'; import { notification } from './notifications.js'; -import { initCaddyStatus } from './caddy.js'; import { createCustomSelect } from './ui.js'; const DOMElements = { globalForm: document.getElementById('global-caddy-form'), - themeToggleInput: document.getElementById('theme-toggle-input'), logoutBtn: document.getElementById('logout-btn'), - toastContainer: document.getElementById('toast-container'), - dialogContainer: document.getElementById('dialog-container'), enableDnsChallengeCheckbox: document.getElementById('enable_dns_challenge'), globalTlsConfigGroup: document.getElementById('global-tls-config-group'), enableEchCheckbox: document.getElementById('enable_ech'), echConfigGroup: document.getElementById('ech-config-group'), }; -const submitButton = DOMElements.globalForm.querySelector('button[type="submit"]'); +// submitButton 在 pageInit 中获取, 确保DOM已加载 +let submitButton; -// 从表单收集数据, 构建成后端需要的JSON结构 function getGlobalConfigFromForm() { const formData = new FormData(DOMElements.globalForm); const enableEch = DOMElements.enableEchCheckbox.checked; @@ -49,7 +45,6 @@ function getGlobalConfigFromForm() { }; } -// 用从API获取的数据填充表单 function fillGlobalConfigForm(config) { if (!config) return; @@ -100,7 +95,6 @@ async function handleSaveGlobalConfig(e) { submitButton.querySelector('span').textContent = "保存中..."; try { - // 修正: 更新API端点路径 const result = await api.put('/global/config', configData); notification.toast(result.message || '全局配置已成功保存,Caddy正在重载...', 'success'); } catch (error) { @@ -118,23 +112,19 @@ async function handleLogout() { } } -function init() { - theme.init(DOMElements.themeToggleInput); - notification.init(DOMElements.toastContainer, DOMElements.dialogContainer); - activateNav('global'); - initCaddyStatus(); +// 页面特有的初始化逻辑 +function pageInit() { + // 在这里获取 submitButton, 确保 DOM 已加载 + submitButton = DOMElements.globalForm.querySelector('button[type="submit"]'); - // 修正: 更新API端点路径 api.get('/global/log/levels') .then(levels => createCustomSelect('select-log-level', Object.keys(levels))) .catch(err => notification.toast(`加载日志级别失败: ${err.message}`, 'error')); - // 修正: 更新API端点路径 api.get('/global/tls/providers') .then(providers => createCustomSelect('select-tls-provider', Object.keys(providers))) .catch(err => notification.toast(`加载TLS提供商失败: ${err.message}`, 'error')); - // 修正: 更新API端点路径 api.get('/global/config') .then(config => fillGlobalConfigForm(config)) .catch(err => notification.toast(`加载全局配置失败: ${err.message}`, 'error')); @@ -150,4 +140,5 @@ function init() { }); } -init(); \ No newline at end of file +// 使用通用初始化函数启动页面 +initializePage({ pageId: 'global', pageInit: pageInit }); \ No newline at end of file diff --git a/frontend/js/init.js b/frontend/js/init.js index 6d69caa..d8b870e 100644 --- a/frontend/js/init.js +++ b/frontend/js/init.js @@ -1,150 +1,215 @@ -// js/init.js +// js/init.js - 初始化页面的独立逻辑 + document.addEventListener('DOMContentLoaded', () => { + const DOMElements = { initForm: document.getElementById('init-form'), - initButton: null, toastContainer: document.getElementById('toast-container'), - usernameInput: document.getElementById('username'), - passwordInput: document.getElementById('password'), - confirmPasswordInput: document.getElementById('confirm_password'), + langSwitcherBtn: document.getElementById('lang-switcher-btn'), + langOptionsList: document.getElementById('lang-options-list'), // 从第一个片段引入 }; - + const initButton = DOMElements.initForm.querySelector('button[type="submit"]'); const INIT_API_URL = '/v0/api/auth/init'; const PASSWORD_MIN_LENGTH = 8; - const TOAST_DEFAULT_DURATION = 3000; - const REDIRECT_DELAY_SUCCESS = 1500; - - const theme = { - init: () => { - const storedTheme = localStorage.getItem('theme'); - const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light'); - document.documentElement.dataset.theme = currentTheme; - } - }; - - const 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; + + const i18n = { + currentLocale: {}, + currentLang: 'en', + // 从第一个片段引入, 使用对象更方便显示语言名称 + supportedLangs: { 'en': 'English', 'zh-CN': '简体中文' }, + t: function(key, replacements = {}) { + const translation = key.split('.').reduce((obj, k) => obj && obj[k], this.currentLocale) || key; + let result = translation; + if (typeof result === 'string') { + for (const placeholder in replacements) { + result = result.replace(`{${placeholder}}`, replacements[placeholder]); + } } - toast._container = containerElement; - toast._container.addEventListener('click', (e) => { - if (e.target.dataset.toastClose !== undefined) { - toast._hideToast(e.target.closest('.toast')); + 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.init.page_title'); }, - - show: (message, type = 'info', duration = TOAST_DEFAULT_DURATION) => { - if (!toast._container) { - console.error('Toast module not initialized. Container is missing.'); - return; + loadLocale: async function(lang) { + try { + const response = await fetch(`/locales/${lang}.json`); + if (!response.ok) throw new Error('File not found'); + this.currentLocale = await response.json(); + this.currentLang = lang; + document.documentElement.lang = lang; // 设置HTML语言属性 + localStorage.setItem('appLanguage', lang); // 从第一个片段引入, 保存到localStorage + } catch (e) { + console.error(`Could not load locale for ${lang}, using fallback.`, e); + this.currentLocale = {}; } + }, + init: async function() { + // 从第一个片段引入, 优先使用保存的语言, 其次使用浏览器语言 + const savedLang = localStorage.getItem('appLanguage'); + const browserLang = navigator.language.startsWith('zh') ? 'zh-CN' : 'en'; + const langToLoad = savedLang || browserLang; + await this.loadLocale(langToLoad); + this.applyTranslations(); + this.populateLangOptions(); // 从第一个片段引入, 初始化语言选项列表 + }, + // 从第一个片段引入, 用于动态生成语言选项列表 + populateLangOptions: function() { + // 清空现有选项 + DOMElements.langOptionsList.innerHTML = ''; + for (const [code, name] of Object.entries(this.supportedLangs)) { + const li = document.createElement('li'); + li.dataset.lang = code; + li.textContent = name; + if (code === this.currentLang) { + li.classList.add('active'); // 标记当前选中语言 + } + DOMElements.langOptionsList.appendChild(li); + } + } + // 移除 i18n.toggleLanguage, 因为有新的语言选择机制 + }; - const iconClass = toast._icons[type] || 'fa-info-circle'; + // 从第二个片段完整引入toast对象 + const toast = { + show: function(message, type = 'info', duration = 3000) { + if (!DOMElements.toastContainer) return; + const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' }; const toastElement = document.createElement('div'); toastElement.className = `toast ${type}`; - toastElement.innerHTML = ` - -

${message}

- - `; - toast._container.appendChild(toastElement); + toastElement.innerHTML = `

${message}

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

${message}

`; + toastElement.innerHTML = `

${message}

`; DOMElements.toastContainer.appendChild(toastElement); requestAnimationFrame(() => toastElement.classList.add('show')); - const timeoutId = setTimeout(() => hideToast(toastElement), duration); + const timeoutId = setTimeout(() => this._hide(toastElement), duration); toastElement.querySelector('[data-toast-close]').addEventListener('click', () => { clearTimeout(timeoutId); - hideToast(toastElement); + this._hide(toastElement); }); + }, + _hide: function(toastElement) { + if (!toastElement) return; + toastElement.classList.remove('show'); + toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true }); } }; - function hideToast(toastElement) { - if (!toastElement) return; - toastElement.classList.remove('show'); - toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true }); - } + // 从第二个片段完整引入handleLogin函数 async function handleLogin(e) { e.preventDefault(); - - // 获取并确认值 const username = DOMElements.loginForm.username.value.trim(); const password = DOMElements.loginForm.password.value.trim(); if (username === '') { - toast.show('用户名不能为空', 'error'); + toast.show(i18n.t('toasts.error_username_empty'), 'error'); DOMElements.loginForm.username.focus(); return; } - if (password === '') { - toast.show('密码不能为空', 'error'); + toast.show(i18n.t('toasts.error_password_empty'), 'error'); DOMElements.loginForm.password.focus(); return; } loginButton.disabled = true; - loginButton.querySelector('span').textContent = '登录中...'; + loginButton.querySelector('span').textContent = i18n.t('pages.login.logging_in_btn'); try { const response = await fetch(LOGIN_API_URL, { @@ -67,23 +142,57 @@ document.addEventListener('DOMContentLoaded', () => { }); const result = await response.json(); if (response.ok) { - toast.show('登录成功,正在跳转...', 'success'); + toast.show(i18n.t('toasts.login_success'), 'success'); setTimeout(() => { window.location.href = '/'; }, 500); } else { - throw new Error(result.error || `登录失败: ${response.status}`); + throw new Error(result.error || i18n.t('toasts.login_error_generic')); } } catch (error) { toast.show(error.message, 'error'); loginButton.disabled = false; - loginButton.querySelector('span').textContent = '登录'; + loginButton.querySelector('span').textContent = i18n.t('pages.login.login_btn'); } } - function init() { - theme.init(); + async function initApp() { + // 主题设置逻辑 + const storedTheme = localStorage.getItem('theme'); + const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + document.documentElement.dataset.theme = storedTheme || (systemPrefersDark ? 'dark' : 'light'); + + // 初始化国际化 + await i18n.init(); + + // 登录表单事件监听 if (DOMElements.loginForm) { DOMElements.loginForm.addEventListener('submit', handleLogin); } + // 语言切换按钮事件监听 (从第一个片段引入) + if (DOMElements.langSwitcherBtn) { + DOMElements.langSwitcherBtn.addEventListener('click', (e) => { + e.stopPropagation(); // 阻止事件冒泡, 防止立即触发document的点击事件 + DOMElements.langOptionsList.classList.toggle('hidden'); + }); + } + // 语言选项列表事件监听 (从第一个片段引入) + 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'); + } + }); } - init(); + + initApp(); }); \ No newline at end of file diff --git a/frontend/js/notifications.js b/frontend/js/notifications.js index 6111dd3..e0bf9f7 100644 --- a/frontend/js/notifications.js +++ b/frontend/js/notifications.js @@ -1,8 +1,9 @@ // js/notifications.js - 提供Toast和Dialog两种通知 -// 这个模块在初始化时需要知道容器的DOM元素 let toastContainer; let dialogContainer; +let modalContainer; // 虽然在此文件中不直接使用, 但 init 中保留以示完整 +let t; // 模块级翻译函数变量 function hideToast(toastElement) { if (!toastElement) return; @@ -11,11 +12,14 @@ function hideToast(toastElement) { } export const notification = { - init: (toastEl, dialogEl) => { + init: (toastEl, dialogEl, modalEl, translator) => { toastContainer = toastEl; dialogContainer = dialogEl; + modalContainer = modalEl; + t = translator; // 保存从外部传入的翻译函数 }, toast: (message, type = 'info', duration = 3000) => { + if (!toastContainer) return; const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle', warning: 'fa-exclamation-triangle' }; const iconClass = icons[type] || 'fa-info-circle'; const toastElement = document.createElement('div'); @@ -29,28 +33,44 @@ export const notification = { hideToast(toastElement); }); }, - confirm: (message) => { + confirm: (message, title = '', options = {}) => { return new Promise(resolve => { + if (!dialogContainer || !t) { + // 如果模块未初始化, 提供一个浏览器默认的 confirm作为回退 + console.warn('Notification module not initialized. Falling back to native confirm.'); + resolve(window.confirm(message)); + return; + } + + // 使用 t 函数翻译按钮文本, 如果 options 中提供了自定义键, 则优先使用 + const confirmText = options.confirmText || t('dialogs.confirm_btn'); + const cancelText = options.cancelText || t('dialogs.cancel_btn'); + const dialogHTML = `
+ ${title ? `

${title}

` : ''}

${message}

- - + +
`; + dialogContainer.innerHTML = dialogHTML; dialogContainer.classList.add('active'); + const eventHandler = (e) => { const actionButton = e.target.closest('[data-action]'); if (!actionButton) return; closeDialog(actionButton.dataset.action === 'confirm'); }; + const closeDialog = (result) => { dialogContainer.removeEventListener('click', eventHandler); dialogContainer.classList.remove('active'); setTimeout(() => { dialogContainer.innerHTML = ''; resolve(result); }, 200); }; + dialogContainer.addEventListener('click', eventHandler); }); } diff --git a/frontend/js/settings.js b/frontend/js/settings.js index 8e91bfb..5360f38 100644 --- a/frontend/js/settings.js +++ b/frontend/js/settings.js @@ -1,18 +1,14 @@ // js/settings.js - 设置页面的逻辑 -import { theme, toast, activateNav } from './common.js'; -import { initCaddyStatus } from './caddy.js'; // 导入 Caddy 状态模块 -import { notification } from './notifications.js'; // 导入通知模块 - -const RESET_PWD_API_URL = '/v0/api/auth/resetpwd'; -const LOGOUT_API_URL = '/v0/api/auth/logout'; +import { initializePage } from './common.js'; +import { api } from './api.js'; +import { notification } from './notifications.js'; +import { t, setLanguage, getCurrentLanguage } from './locale.js'; +import { createCustomSelect } from './ui.js'; const DOMElements = { resetForm: document.getElementById('reset-password-form'), - themeToggleInput: document.getElementById('theme-toggle-input'), logoutBtn: document.getElementById('logout-btn'), - toastContainer: document.getElementById('toast-container'), - dialogContainer: document.getElementById('dialog-container'), }; const resetButton = DOMElements.resetForm.querySelector('button[type="submit"]'); @@ -20,81 +16,62 @@ async function handleResetPassword(e) { e.preventDefault(); const newPassword = DOMElements.resetForm.new_password.value; const confirmPassword = DOMElements.resetForm.confirm_new_password.value; - - //保证字段均不为空, 用户名 密码 新密码 const currentPassword = DOMElements.resetForm.old_password.value; const username = DOMElements.resetForm.username.value; - if (username === '') { - toast.show('用户名不能为空', 'error'); - DOMElements.resetForm.username.focus(); + if (!username || !currentPassword || !newPassword || !confirmPassword) { + notification.toast(t('toasts.error_all_fields_required'), 'error'); return; } - - if (currentPassword === '') { - toast.show('当前密码不能为空', 'error'); - DOMElements.resetForm.old_password.focus(); - return; - } - if (newPassword === '') { - notification.toast('新密码不能为空', 'error'); - DOMElements.resetForm.new_password.focus(); - return; - } - if (confirmPassword === '') { - notification.toast('确认新密码不能为空', 'error'); - DOMElements.resetForm.confirm_new_password.focus(); - return; - } - if (newPassword !== confirmPassword) { - notification.toast('新密码与确认密码不匹配', 'error'); + notification.toast(t('toasts.init_error_mismatch'), 'error'); return; } if (newPassword.length < 8) { - notification.toast('新密码长度至少为8位', 'error'); + notification.toast(t('toasts.init_error_short'), 'error'); return; } resetButton.disabled = true; - resetButton.querySelector('span').textContent = '重置中...'; - - const formData = new FormData(DOMElements.resetForm); + resetButton.querySelector('span').textContent = t('pages.settings.resetting_password_btn'); try { - const response = await fetch(RESET_PWD_API_URL, { - method: 'POST', - body: new URLSearchParams(formData), - }); - const result = await response.json(); - if (response.ok) { - notification.toast('密码重置成功!请重新登录。', 'success'); - setTimeout(() => { window.location.href = LOGOUT_API_URL; }, 1500); - } else { - throw new Error(result.error || '重置密码失败'); - } + const result = await api.post('/auth/resetpwd', new URLSearchParams(new FormData(DOMElements.resetForm))); + notification.toast(t('toasts.pwd_reset_success'), 'success'); + setTimeout(() => { window.location.href = '/v0/api/auth/logout'; }, 1500); } catch (error) { - notification.toast(error.message, 'error'); + notification.toast(`${t('common.error_prefix')}: ${error.message}`, 'error'); resetButton.disabled = false; - resetButton.querySelector('span').textContent = '重置密码'; + resetButton.querySelector('span').textContent = t('pages.settings.reset_password_btn'); } } async function handleLogout() { - if (await notification.confirm('您确定要退出登录吗?')) { - notification.toast('正在退出...', 'info'); - setTimeout(() => { window.location.href = LOGOUT_API_URL; }, 500); + if (await notification.confirm(t('dialogs.logout_msg'))) { + notification.toast(t('toasts.logout_processing'), 'info'); + setTimeout(() => { window.location.href = '/v0/api/auth/logout'; }, 500); } } -function init() { - theme.init(DOMElements.themeToggleInput); - notification.init(DOMElements.toastContainer, DOMElements.dialogContainer); - activateNav('settings'); - initCaddyStatus(); // 初始化通用Caddy状态检查 +// 页面特有的初始化逻辑 +function pageInit() { + const langOptions = { 'en': 'English', 'zh-CN': '简体中文' }; + const langSelectOptions = Object.keys(langOptions).map(key => ({ name: langOptions[key], value: key })); + + createCustomSelect('select-language', langSelectOptions, (selectedValue) => { + setLanguage(selectedValue); + }); + + const langSelect = document.getElementById('select-language'); + if (langSelect) { + const currentLangName = langOptions[getCurrentLanguage()]; + const selectedDiv = langSelect.querySelector('.select-selected'); + if (selectedDiv) selectedDiv.textContent = currentLangName; + } DOMElements.resetForm.addEventListener('submit', handleResetPassword); DOMElements.logoutBtn.addEventListener('click', handleLogout); } -init(); \ No newline at end of file +// 使用通用初始化函数 +initializePage({ pageId: 'settings', pageInit: pageInit }); \ No newline at end of file diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 7aa6a09..05e016e 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -1,5 +1,13 @@ // js/ui.js - 管理所有与UI渲染和DOM操作相关的函数 +// 模块级私有变量, 用于存储翻译函数 +let t; + +// 新增: 初始化函数, 用于接收翻译函数 +export function initUI(translator) { + t = translator; +} + export const DOMElements = { sidebar: document.getElementById('sidebar'), menuToggleBtn: document.getElementById('menu-toggle-btn'), @@ -45,14 +53,14 @@ export function switchView(viewToShow) { export function renderConfigList(filenames) { DOMElements.configListContainer.innerHTML = ''; if (!filenames || filenames.length === 0) { - DOMElements.configListContainer.innerHTML = '

还没有任何配置,请创建一个。

'; + DOMElements.configListContainer.innerHTML = `

${t('configs.no_configs')}

`; return; } filenames.forEach(filename => { const item = document.createElement('li'); item.className = 'config-item'; item.dataset.filename = filename; - item.innerHTML = `${filename}
`; + item.innerHTML = `${filename}
`; DOMElements.configListContainer.appendChild(item); }); } @@ -61,21 +69,21 @@ export function addKeyValueInput(container, keyName, valueName, key = '', value const div = document.createElement('div'); div.className = 'header-entry'; div.innerHTML = ` - - - `; container.appendChild(div); } -export function addSingleInput(container, inputName, placeholder, value = '') { +export function addSingleInput(container, inputName, placeholderKey, value = '') { const div = document.createElement('div'); div.className = 'header-entry'; div.style.gridTemplateColumns = '1fr auto'; div.innerHTML = ` - - `; container.appendChild(div); @@ -97,7 +105,7 @@ export function fillForm(config, originalFilename) { DOMElements.multiUpstreamContainer.innerHTML = ''; if (upstreamConfig.muti_upstream && upstreamConfig.upstream_servers) { upstreamConfig.upstream_servers.forEach(server => { - addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', '例如: 127.0.0.1:8081', server); + addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', 'form.upstream_server_placeholder', server); }); } DOMElements.upstreamHeadersContainer.innerHTML = ''; @@ -137,21 +145,22 @@ export function updateCaddyStatusView(status, handlers) { const dot = DOMElements.caddyStatusIndicator.querySelector('.status-dot'); const text = DOMElements.caddyStatusIndicator.querySelector('.status-text'); const buttonContainer = DOMElements.caddyActionButtonContainer; + if (!dot || !text || !buttonContainer) return; dot.className = 'status-dot'; buttonContainer.innerHTML = ''; let statusText, dotClass; switch (status) { case 'running': - statusText = '运行中'; dotClass = 'running'; - buttonContainer.appendChild(createButton('重载配置', 'btn-warning', handleReloadCaddy)); - buttonContainer.appendChild(createButton('停止 Caddy', 'btn-danger', handleStopCaddy)); + statusText = t('status.running'); dotClass = 'running'; + buttonContainer.appendChild(createButton(t('caddy.reload_btn'), 'btn-warning', handleReloadCaddy)); + buttonContainer.appendChild(createButton(t('caddy.stop_btn'), 'btn-danger', handleStopCaddy)); break; case 'stopped': - statusText = '已停止'; dotClass = 'stopped'; - buttonContainer.appendChild(createButton('启动 Caddy', 'btn-success', handleStartCaddy)); + statusText = t('status.stopped'); dotClass = 'stopped'; + buttonContainer.appendChild(createButton(t('caddy.start_btn'), 'btn-success', handleStartCaddy)); break; - case 'checking': statusText = '检查中...'; dotClass = 'checking'; break; - default: statusText = '状态未知'; dotClass = 'error'; break; + case 'checking': statusText = t('status.checking'); dotClass = 'checking'; break; + default: statusText = t('status.unknown'); dotClass = 'error'; break; } text.textContent = statusText; dot.classList.add(dotClass); @@ -177,24 +186,21 @@ export function updateSegmentedControl(activeButton) { slider.style.transform = `translateX(${activeButton.offsetLeft}px)`; } -// 彻底重构: 使用 Promise 并管理自己的事件监听器生命周期 export function createPresetSelectionModal(presets) { return new Promise(resolve => { const modalContainer = DOMElements.modalContainer; - if (!modalContainer) return resolve(null); // 安全退出 - + if (!modalContainer) return resolve(null); const presetItems = presets.map(p => `
  • - ${p.name} -

    ${p.description}

    + ${t(p.name_key) || p.name} +

    ${t(p.desc_key) || p.description}

  • `).join(''); - const modalHTML = ` `; - modalContainer.innerHTML = modalHTML; requestAnimationFrame(() => modalContainer.classList.add('active')); - const cleanupAndResolve = (value) => { modalContainer.removeEventListener('click', eventHandler); modalContainer.classList.remove('active'); - setTimeout(() => { - modalContainer.innerHTML = ''; - resolve(value); - }, 300); + setTimeout(() => { modalContainer.innerHTML = ''; resolve(value); }, 300); }; - const eventHandler = (e) => { if (e.target.classList.contains('modal-overlay') || e.target.closest('[data-modal-close]')) { - cleanupAndResolve(null); // 用户取消 + cleanupAndResolve(null); } const listItem = e.target.closest('li[data-preset-id]'); if (listItem) { - cleanupAndResolve(listItem.dataset.presetId); // 用户选择 + cleanupAndResolve(listItem.dataset.presetId); } }; - modalContainer.addEventListener('click', eventHandler); }); } @@ -233,28 +232,30 @@ export function createCustomSelect(containerId, options, onSelect) { const container = document.getElementById(containerId); if (!container) return; - // 从容器的ID动态生成隐藏input的name属性 - // 例如, id 'select-log-level' -> name 'log_level' const inputName = container.id.replace('select-', '').replace(/-/g, '_'); - container.innerHTML = `
    `; + const selectedDiv = container.querySelector('.select-selected'); const itemsDiv = container.querySelector('.select-items'); const hiddenInput = container.querySelector('input[type="hidden"]'); itemsDiv.innerHTML = ''; if (!options || options.length === 0) { - selectedDiv.textContent = '无可用选项'; + selectedDiv.textContent = t('common.no_options'); return; } options.forEach((option, index) => { const item = document.createElement('div'); - item.textContent = option; - item.dataset.value = option; + const optionText = typeof option === 'object' ? option.name : option; + const optionValue = typeof option === 'object' ? option.value : option; + + item.textContent = optionText; + item.dataset.value = optionValue; + if (index === 0) { - selectedDiv.textContent = option; - hiddenInput.value = option; + selectedDiv.textContent = optionText; + hiddenInput.value = optionValue; } item.addEventListener('click', function(e) { selectedDiv.textContent = this.textContent; @@ -266,6 +267,7 @@ export function createCustomSelect(containerId, options, onSelect) { }); itemsDiv.appendChild(item); }); + selectedDiv.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.select-items.select-show').forEach(openSelect => { @@ -277,6 +279,7 @@ export function createCustomSelect(containerId, options, onSelect) { itemsDiv.classList.toggle('select-show'); selectedDiv.classList.toggle('select-arrow-active'); }); + document.addEventListener('click', () => { itemsDiv.classList.remove('select-show'); if(selectedDiv) selectedDiv.classList.remove('select-arrow-active'); diff --git a/frontend/locales/en.json b/frontend/locales/en.json new file mode 100644 index 0000000..b73980b --- /dev/null +++ b/frontend/locales/en.json @@ -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." + } +} \ No newline at end of file diff --git a/frontend/locales/zh-CN.json b/frontend/locales/zh-CN.json new file mode 100644 index 0000000..b4929fe --- /dev/null +++ b/frontend/locales/zh-CN.json @@ -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": "初始化失败,请重试" + } +} \ No newline at end of file diff --git a/frontend/login.html b/frontend/login.html index 436dc83..211f7ba 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -1,44 +1,56 @@ + - 登录 - CaddyDash + 登录 - CaddyDash +
    -

    CaddyDash

    -

    请输入您的凭证以继续

    +

    欢迎使用 CaddyDash

    +

    请输入您的凭证以继续

    - +
    - +
    - +
    - -
    + +
    + + + +
    - +
    + + \ No newline at end of file diff --git a/frontend/settings.html b/frontend/settings.html index 00b7bcf..34e5fc4 100644 --- a/frontend/settings.html +++ b/frontend/settings.html @@ -22,7 +22,8 @@