From be5185381571797c55358c700ad289cfe1eda430 Mon Sep 17 00:00:00 2001 From: smail Date: Sat, 5 Sep 2026 22:31:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E9=85=8D=E7=BD=AE=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E9=A1=B5=E9=9D=A2=E5=B9=B6=E5=9C=A8=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=90=88=E5=B9=B6=E5=89=8D=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConfigSettings/BaseConfig/BaseConfig.vue | 13 + .../ConfigEditor/ConfigEditor.vue | 196 ++++++++ .../ConfigMaintenance/ConfigMaintenance.js | 12 + .../ConfigMaintenance/ConfigMaintenance.vue | 174 +++++++ .../ConfigSettings/ConfigNavigation.js | 54 +++ .../ConfigSettings/ConfigSettings.css | 30 ++ .../ConfigSettings/ConfigSettings.js | 79 ++- .../ConfigSettings/ConfigSettings.vue | 448 ++---------------- .../EmailConfig/EmailConfig.vue | 13 + .../PluginsConfig/PluginsConfig.vue | 13 + .../ConfigSettings/SmtpConfig/SmtpConfig.vue | 13 + .../ConfigSettings/SslConfig/SslConfig.vue | 13 + .../views/components/HomeHeader/HomeHeader.js | 23 +- .../components/HomeHeader/HomeHeader.vue | 8 +- test/frontend/config-navigation.test.mjs | 60 +++ test/frontend/config-settings.test.mjs | 182 +++++-- 16 files changed, 853 insertions(+), 478 deletions(-) create mode 100644 frontend/src/views/components/ConfigSettings/BaseConfig/BaseConfig.vue create mode 100644 frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue create mode 100644 frontend/src/views/components/ConfigSettings/ConfigMaintenance/ConfigMaintenance.js create mode 100644 frontend/src/views/components/ConfigSettings/ConfigMaintenance/ConfigMaintenance.vue create mode 100644 frontend/src/views/components/ConfigSettings/ConfigNavigation.js create mode 100644 frontend/src/views/components/ConfigSettings/EmailConfig/EmailConfig.vue create mode 100644 frontend/src/views/components/ConfigSettings/PluginsConfig/PluginsConfig.vue create mode 100644 frontend/src/views/components/ConfigSettings/SmtpConfig/SmtpConfig.vue create mode 100644 frontend/src/views/components/ConfigSettings/SslConfig/SslConfig.vue create mode 100644 test/frontend/config-navigation.test.mjs diff --git a/frontend/src/views/components/ConfigSettings/BaseConfig/BaseConfig.vue b/frontend/src/views/components/ConfigSettings/BaseConfig/BaseConfig.vue new file mode 100644 index 0000000..515df49 --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/BaseConfig/BaseConfig.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue new file mode 100644 index 0000000..d395e57 --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/frontend/src/views/components/ConfigSettings/ConfigMaintenance/ConfigMaintenance.js b/frontend/src/views/components/ConfigSettings/ConfigMaintenance/ConfigMaintenance.js new file mode 100644 index 0000000..547bd83 --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/ConfigMaintenance/ConfigMaintenance.js @@ -0,0 +1,12 @@ +import { usePage } from '../ConfigSettings.js' + +export function useMaintenance(pageKey) { + const page = usePage(pageKey) + const selectBackup = generation => { + if (page.working.value) return + page.rollbackGeneration.value = generation + page.rollbackPreview.value = null + page.rollbackConfirmed.value = false + } + return { ...page, selectBackup } +} diff --git a/frontend/src/views/components/ConfigSettings/ConfigMaintenance/ConfigMaintenance.vue b/frontend/src/views/components/ConfigSettings/ConfigMaintenance/ConfigMaintenance.vue new file mode 100644 index 0000000..a83412a --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/ConfigMaintenance/ConfigMaintenance.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/frontend/src/views/components/ConfigSettings/ConfigNavigation.js b/frontend/src/views/components/ConfigSettings/ConfigNavigation.js new file mode 100644 index 0000000..84f338b --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/ConfigNavigation.js @@ -0,0 +1,54 @@ +import { computed, nextTick, ref } from 'vue' +import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' +import BaseConfig from './BaseConfig/BaseConfig.vue' +import SmtpConfig from './SmtpConfig/SmtpConfig.vue' +import EmailConfig from './EmailConfig/EmailConfig.vue' +import SslConfig from './SslConfig/SslConfig.vue' +import PluginsConfig from './PluginsConfig/PluginsConfig.vue' +import ConfigMaintenance from './ConfigMaintenance/ConfigMaintenance.vue' + +export function useNavigation() { + const store = useGlobalStatusStore() + const selectedSection = ref('base') + const currentPage = ref(null) + const pageHost = ref(null) + const switching = ref(false) + const pages = [ + { value: 'base', label: '基础配置', component: BaseConfig }, + { value: 'smtp', label: 'SMTP 配置', component: SmtpConfig }, + { value: 'email', label: '邮箱策略', component: EmailConfig }, + { value: 'ssl', label: 'SSL 配置', component: SslConfig }, + { value: 'plugins', label: '插件配置', component: PluginsConfig } + ] + const tools = [ + { value: 'migration', label: '数据库迁移', capability: 'database.migrate' }, + { value: 'restart', label: '服务重启', capability: 'service.restart' }, + { value: 'backups', label: '备份与回滚', capability: 'config.rollback' }, + { value: 'audit', label: '配置审计', capability: 'audit.read' } + ] + const sectionOptions = computed(() => store.hasCapability('config.read') ? pages : []) + const maintenanceOptions = computed(() => store.hasCapability('config.read') + ? tools.filter(item => store.hasCapability(item.capability)) + .map(item => ({ ...item, component: ConfigMaintenance })) + : []) + const activePage = computed(() => [...sectionOptions.value, ...maintenanceOptions.value] + .find(item => item.value === selectedSection.value)) + + const beforeLeave = () => currentPage.value?.beforeLeave?.() ?? true + const selectSection = async value => { + const visible = () => [...sectionOptions.value, ...maintenanceOptions.value].some(item => item.value === value) + if (switching.value || value === selectedSection.value || !visible()) return + switching.value = true + try { + if (!await beforeLeave() || !visible()) return + selectedSection.value = value + await nextTick() + pageHost.value?.closest('.el-tabs__content')?.scrollTo({ top: 0 }) + pageHost.value?.focus({ preventScroll: true }) + } finally { + switching.value = false + } + } + + return { sectionOptions, maintenanceOptions, selectedSection, activePage, currentPage, pageHost, selectSection, beforeLeave } +} diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.css b/frontend/src/views/components/ConfigSettings/ConfigSettings.css index e362307..6d2d57f 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.css +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.css @@ -4,6 +4,24 @@ padding-bottom: 16px; --config-accent: var(--pm-primary-color); } +.config-page, +.config-page-body { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + min-width: 0; +} +.config-navigation { + grid-area: submenu; + position: sticky; + top: 0; + display: grid; + gap: 20px; +} +.config-navigation > .settings-submenu { + grid-area: auto; + position: static; +} .settings-header, .panel-heading, .status-heading { @@ -147,6 +165,14 @@ .config-main > * { min-width: 0; } +.config-main:focus { + outline: none; +} +.config-main:focus-visible { + outline: 2px solid color-mix(in srgb, var(--config-accent) 35%, transparent); + outline-offset: -2px; + border-radius: 12px; +} .panel-heading { flex-wrap: wrap; } @@ -512,6 +538,10 @@ .selected-section-label { white-space: normal; } + .config-navigation { + gap: 12px; + position: static; + } } .panel-desc { margin: 0; diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index aa210ba..f5d31a3 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -1,4 +1,4 @@ -import { computed, onMounted, reactive, ref } from 'vue' +import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { http } from '@/utils/axios' import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' @@ -28,7 +28,8 @@ const operationLabels = { rollback: '执行回滚' } -export function usePage() { +export function usePage(pageKey = 'base') { + const isConfigPage = sections.some(item => item.value === pageKey) const globalStatus = useGlobalStatusStore() const loading = ref(false) const availabilityError = ref('') @@ -44,11 +45,13 @@ export function usePage() { pendingRestart: [], degradedReasons: [] }) - const selectedSection = ref('base') + const selectedSection = ref(isConfigPage ? pageKey : 'base') const currentSection = computed(() => sections.find(item => item.value === selectedSection.value) || sections[0]) const sectionLoading = ref(false) let sectionRequestId = 0 + let disposed = false const editorText = ref('') + const loadedText = ref('') const sectionError = ref('') const secretValue = ref('') const confirmed = ref(false) @@ -65,6 +68,7 @@ export function usePage() { const migrationToken = ref('') const migrationRecord = ref(null) const restartPreview = ref(null) + const hasChanges = computed(() => editorText.value !== loadedText.value || !!secretValue.value || !!savedGeneration.value) const canWrite = computed(() => globalStatus.hasCapability('config.write_global')) const canRollback = computed(() => globalStatus.hasCapability('config.rollback')) @@ -127,6 +131,7 @@ export function usePage() { sectionLoading.value = true sectionError.value = '' editorText.value = '' + loadedText.value = '' secretValue.value = '' confirmed.value = false validationResult.value = null @@ -137,6 +142,7 @@ export function usePage() { notifyResponseError(response, '配置分区读取失败') const publicConfig = response.data || {} editorText.value = JSON.stringify(publicConfig, null, 2) + loadedText.value = editorText.value if (section === 'base') { const database = publicConfig.database || {} Object.assign(databaseConfig, { type: database.type || '', name: database.name || '', localRef: database.localRef || '' }) @@ -150,12 +156,6 @@ export function usePage() { } } - const selectSection = value => { - if (selectedSection.value === value || working.value || !sections.some(item => item.value === value)) return - selectedSection.value = value - return loadSection() - } - const loadBackups = async () => { if (!canRollback.value) return backupsLoading.value = true @@ -192,7 +192,7 @@ export function usePage() { } const previewDatabaseMigration = async () => { - if (!canMigrate.value || !migrationTargetName.value.trim()) return + if (!canMigrate.value || working.value || sectionError.value || !migrationTargetName.value.trim()) return working.value = 'migration-preview' migrationPreview.value = null migrationRecord.value = null @@ -229,7 +229,8 @@ export function usePage() { } const executeDatabaseMigration = async () => { - if (!migrationPreview.value?.id || !migrationToken.value) return + if (!canMigrate.value || working.value || !migrationPreview.value?.id || !migrationToken.value) return + working.value = 'migration-confirm' try { await ElMessageBox.confirm( '迁移将复制数据库内容并切换配置,旧数据库会保留。确认继续吗?', @@ -259,7 +260,7 @@ export function usePage() { } const previewRestart = async () => { - if (!canRestart.value) return + if (!canRestart.value || working.value) return working.value = 'restart-preview' try { const response = await http.post('/api/v1/system/restart/preview', {}) @@ -273,7 +274,8 @@ export function usePage() { } const requestRestart = async () => { - if (!canRestart.value) return + if (!canRestart.value || working.value || !restartPreview.value?.triggerAvailable) return + working.value = 'restart-confirm' try { await ElMessageBox.confirm( '服务将按固定服务管理器重启,当前页面可能会短暂断开。确认继续吗?', @@ -296,11 +298,14 @@ export function usePage() { availabilityError.value = '' try { await loadStatus() + if (disposed) return if (status.available === false) { availabilityError.value = status.unavailableReason || '在线配置管理尚未启用' return } - await Promise.all([loadSection(), loadBackups(), loadAudit()]) + if (isConfigPage || (pageKey === 'migration' && canMigrate.value)) await loadSection() + if (pageKey === 'backups') await loadBackups() + if (pageKey === 'audit') await loadAudit() } catch (error) { availabilityError.value = error.message || '配置管理数据读取失败' } finally { @@ -351,6 +356,7 @@ export function usePage() { } const validateConfig = async () => { + if (!canWrite.value || working.value || sectionLoading.value || sectionError.value) return working.value = 'validate' try { const request = buildRequest() @@ -366,6 +372,7 @@ export function usePage() { } const saveConfig = async () => { + if (!canWrite.value || working.value || sectionLoading.value || sectionError.value) return working.value = 'save' try { const request = buildRequest() @@ -383,7 +390,7 @@ export function usePage() { } const applyConfig = async () => { - if (!savedGeneration.value) return + if (!canWrite.value || working.value || !savedGeneration.value) return working.value = 'apply' try { const response = await http.post(`/api/v1/config/${selectedSection.value}/apply`, { generation: savedGeneration.value }) @@ -399,9 +406,10 @@ export function usePage() { } const previewRollback = async () => { - if (!rollbackGeneration.value) return + if (!canRollback.value || working.value || !rollbackGeneration.value) return working.value = 'preview' rollbackPreview.value = null + rollbackConfirmed.value = false try { const response = await http.post('/api/v1/config/rollback/preview', { targetGeneration: rollbackGeneration.value }) notifyResponseError(response, '回滚预览失败') @@ -415,7 +423,8 @@ export function usePage() { } const executeRollback = async () => { - if (!rollbackPreview.value || !rollbackConfirmed.value) return + if (!canRollback.value || working.value || !rollbackPreview.value || !rollbackConfirmed.value) return + working.value = 'rollback-confirm' try { await ElMessageBox.confirm('回滚会替换当前候选配置并可能需要重启服务,确定继续吗?', '确认执行回滚', { type: 'warning', confirmButtonText: '继续回滚', cancelButtonText: '取消' }) working.value = 'rollback' @@ -428,7 +437,7 @@ export function usePage() { notifyResponseError(response, '配置回滚失败') rollbackPreview.value = null rollbackConfirmed.value = false - await Promise.all([loadStatus(), loadSection(), loadBackups()]) + await Promise.all([loadStatus(), loadBackups()]) ElMessage.success('配置已回滚') } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '配置回滚失败') @@ -438,6 +447,7 @@ export function usePage() { } const exportAudit = async format => { + if (!canExportAudit.value || working.value) return working.value = format === 'csv' ? 'export-csv' : 'export' try { const response = await http.post('/api/v1/audits/config', { format, limit: 500 }, { responseType: 'blob' }) @@ -456,11 +466,40 @@ export function usePage() { } } + const beforeLeave = async () => { + if (working.value) { + ElMessage.warning('当前操作尚未完成,请稍后再切换页面') + return false + } + if (!hasChanges.value && !migrationToken.value && !rollbackPreview.value) return true + try { + await ElMessageBox.confirm( + '当前页有编辑内容、待应用候选配置或操作预览。离开会清除本页状态,但不会撤销已保存到服务器的候选配置。确认离开吗?', + '确认离开当前页', + { type: 'warning', confirmButtonText: '离开页面', cancelButtonText: '继续编辑' } + ) + return !working.value + } catch { + return false + } + } + + const refreshPage = async () => { + if (await beforeLeave()) await loadAll() + } + onMounted(loadAll) + onBeforeUnmount(() => { + disposed = true + sectionRequestId++ + secretValue.value = '' + confirmed.value = false + migrationToken.value = '' + }) return { loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections, - selectedSection, currentSection, sectionLoading, selectSection, editorText, sectionError, sensitiveHint, secretField, secretValue, + selectedSection, currentSection, sectionLoading, editorText, sectionError, sensitiveHint, secretField, secretValue, confirmed, savedGeneration, validationResult, canWrite, canRollback, canAudit, canExportAudit, backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed, rollbackPreview, auditLoading, auditError, auditItems, loadAll, loadSection, validateConfig, saveConfig, applyConfig, @@ -468,6 +507,6 @@ export function usePage() { serviceLabel, sectionLabel, operationLabel, databaseConfig, databaseTypeLabel, canMigrate, migrationTargetName, migrationPreview, migrationToken, migrationRecord, migrationStatusLabel, migrationStatusType, previewDatabaseMigration, loadMigrationStatus, executeDatabaseMigration, canRestart, restartPreview, - previewRestart, requestRestart + previewRestart, requestRestart, beforeLeave, refreshPage, hasChanges } } diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.vue b/frontend/src/views/components/ConfigSettings/ConfigSettings.vue index 1a5d056..9bc5bb9 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.vue @@ -1,427 +1,47 @@ diff --git a/frontend/src/views/components/ConfigSettings/EmailConfig/EmailConfig.vue b/frontend/src/views/components/ConfigSettings/EmailConfig/EmailConfig.vue new file mode 100644 index 0000000..4cecad5 --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/EmailConfig/EmailConfig.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/views/components/ConfigSettings/PluginsConfig/PluginsConfig.vue b/frontend/src/views/components/ConfigSettings/PluginsConfig/PluginsConfig.vue new file mode 100644 index 0000000..a81a570 --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/PluginsConfig/PluginsConfig.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/views/components/ConfigSettings/SmtpConfig/SmtpConfig.vue b/frontend/src/views/components/ConfigSettings/SmtpConfig/SmtpConfig.vue new file mode 100644 index 0000000..1949430 --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/SmtpConfig/SmtpConfig.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/views/components/ConfigSettings/SslConfig/SslConfig.vue b/frontend/src/views/components/ConfigSettings/SslConfig/SslConfig.vue new file mode 100644 index 0000000..35f80ae --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/SslConfig/SslConfig.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/views/components/HomeHeader/HomeHeader.js b/frontend/src/views/components/HomeHeader/HomeHeader.js index 70ea06b..fd0d98b 100644 --- a/frontend/src/views/components/HomeHeader/HomeHeader.js +++ b/frontend/src/views/components/HomeHeader/HomeHeader.js @@ -53,6 +53,24 @@ export function usePage() { ].filter(item => globalStatus.hasCapability(item.capability))) const activeSettings = computed(() => settingsMenu.value.find(item => item.key === globalStatus.settingsActiveTab)) + const settingsPages = new Map() + const setSettingsPage = (key, instance) => { + if (instance) settingsPages.set(key, instance) + else settingsPages.delete(key) + } + let checkingLeave = false + const beforeSettingsLeave = async () => { + if (checkingLeave) return false + checkingLeave = true + try { + return await (settingsPages.get(globalStatus.settingsActiveTab)?.beforeLeave?.() ?? true) + } finally { + checkingLeave = false + } + } + const closeSettings = async done => { + if (await beforeSettingsLeave()) done() + } return { EpMenu, @@ -74,7 +92,10 @@ export function usePage() { isMobile, handleResize, settingsMenu, - activeSettings + activeSettings, + setSettingsPage, + beforeSettingsLeave, + closeSettings } } diff --git a/frontend/src/views/components/HomeHeader/HomeHeader.vue b/frontend/src/views/components/HomeHeader/HomeHeader.vue index 7610e82..d4a4cf1 100644 --- a/frontend/src/views/components/HomeHeader/HomeHeader.vue +++ b/frontend/src/views/components/HomeHeader/HomeHeader.vue @@ -25,6 +25,7 @@ class="settings-drawer" :with-header="true" direction="rtl" + :before-close="closeSettings" >